fix(editor): restore configurable completion acceptance
This commit is contained in:
17 files changed
+367
-73
No files matched your search
@@ -332,6 +332,7 @@ const editShowInsertValueHints = ref(settingsStore.editorSettings.showInsertValu
|
||||
const editAutoAliasTables = ref(settingsStore.editorSettings.autoAliasTables);
|
||||
const editInsertSpaceAfterCompletion = ref(settingsStore.editorSettings.insertSpaceAfterCompletion);
|
||||
const editSortCompletionColumnsAlphabetically = ref(settingsStore.editorSettings.sortCompletionColumnsAlphabetically);
|
||||
const editSelectFirstCompletionOnOpen = ref(settingsStore.editorSettings.selectFirstCompletionOnOpen);
|
||||
const editCompletionTriggerMode = ref<SqlCompletionTriggerMode>(settingsStore.editorSettings.completionTriggerMode);
|
||||
const editWordWrap = ref(settingsStore.editorSettings.wordWrap);
|
||||
const editVimModeEnabled = ref(settingsStore.editorSettings.vimModeEnabled);
|
||||
@@ -536,6 +537,7 @@ function currentEditorSettingsDraft(): EditorSettingsDraft {
|
||||
autoAliasTables: editAutoAliasTables.value,
|
||||
insertSpaceAfterCompletion: editInsertSpaceAfterCompletion.value,
|
||||
sortCompletionColumnsAlphabetically: editSortCompletionColumnsAlphabetically.value,
|
||||
selectFirstCompletionOnOpen: editSelectFirstCompletionOnOpen.value,
|
||||
completionTriggerMode: editCompletionTriggerMode.value,
|
||||
wordWrap: editWordWrap.value,
|
||||
vimModeEnabled: editVimModeEnabled.value,
|
||||
@@ -825,6 +827,7 @@ function syncEditorSettingsDraftFromStore() {
|
||||
editAutoAliasTables.value = settingsStore.editorSettings.autoAliasTables;
|
||||
editInsertSpaceAfterCompletion.value = settingsStore.editorSettings.insertSpaceAfterCompletion;
|
||||
editSortCompletionColumnsAlphabetically.value = settingsStore.editorSettings.sortCompletionColumnsAlphabetically;
|
||||
editSelectFirstCompletionOnOpen.value = settingsStore.editorSettings.selectFirstCompletionOnOpen;
|
||||
editCompletionTriggerMode.value = settingsStore.editorSettings.completionTriggerMode;
|
||||
editWordWrap.value = settingsStore.editorSettings.wordWrap;
|
||||
editVimModeEnabled.value = settingsStore.editorSettings.vimModeEnabled;
|
||||
@@ -1079,6 +1082,7 @@ function resetDefaultsForTab(tab: SettingsCategory) {
|
||||
editAutoAliasTables.value = DEFAULT_EDITOR_SETTINGS.autoAliasTables;
|
||||
editInsertSpaceAfterCompletion.value = DEFAULT_EDITOR_SETTINGS.insertSpaceAfterCompletion;
|
||||
editSortCompletionColumnsAlphabetically.value = DEFAULT_EDITOR_SETTINGS.sortCompletionColumnsAlphabetically;
|
||||
editSelectFirstCompletionOnOpen.value = DEFAULT_EDITOR_SETTINGS.selectFirstCompletionOnOpen;
|
||||
editCompletionTriggerMode.value = DEFAULT_EDITOR_SETTINGS.completionTriggerMode;
|
||||
editWordWrap.value = DEFAULT_EDITOR_SETTINGS.wordWrap;
|
||||
editVimModeEnabled.value = DEFAULT_EDITOR_SETTINGS.vimModeEnabled;
|
||||
@@ -1194,6 +1198,7 @@ function resetAllDefaults() {
|
||||
editAutoAliasTables.value = DEFAULT_EDITOR_SETTINGS.autoAliasTables;
|
||||
editInsertSpaceAfterCompletion.value = DEFAULT_EDITOR_SETTINGS.insertSpaceAfterCompletion;
|
||||
editSortCompletionColumnsAlphabetically.value = DEFAULT_EDITOR_SETTINGS.sortCompletionColumnsAlphabetically;
|
||||
editSelectFirstCompletionOnOpen.value = DEFAULT_EDITOR_SETTINGS.selectFirstCompletionOnOpen;
|
||||
editWordWrap.value = DEFAULT_EDITOR_SETTINGS.wordWrap;
|
||||
editVimModeEnabled.value = DEFAULT_EDITOR_SETTINGS.vimModeEnabled;
|
||||
editAutoCloseBrackets.value = DEFAULT_EDITOR_SETTINGS.autoCloseBrackets;
|
||||
@@ -4030,16 +4035,6 @@ onUnmounted(() => {
|
||||
<Switch id="editor-vim-mode" v-model="editVimModeEnabled" class="mt-0.5" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
|
||||
<div class="space-y-1">
|
||||
<Label for="editor-auto-close-brackets">{{ t("settings.autoCloseBrackets") }}</Label>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t("settings.autoCloseBracketsDescription") }}
|
||||
</p>
|
||||
</div>
|
||||
<Switch id="editor-auto-close-brackets" v-model="editAutoCloseBrackets" class="mt-0.5" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
|
||||
<div class="space-y-1">
|
||||
<Label for="regex-max-match-count">{{ t("settings.regexMaxMatchCount") }}</Label>
|
||||
@@ -4055,55 +4050,87 @@ onUnmounted(() => {
|
||||
class="h-7 w-24 px-2 text-xs tabular-nums [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
|
||||
<div class="space-y-1">
|
||||
<Label for="editor-insert-space-after-completion">{{ t("settings.insertSpaceAfterCompletion") }}</Label>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t("settings.insertSpaceAfterCompletionDescription") }}
|
||||
</p>
|
||||
</div>
|
||||
<Switch id="editor-insert-space-after-completion" v-model="editInsertSpaceAfterCompletion" class="mt-0.5" />
|
||||
<Separator />
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="text-sm font-medium text-muted-foreground">
|
||||
{{ t("settings.sqlCompletionSection") }}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
|
||||
<div class="space-y-1">
|
||||
<Label for="editor-sort-completion-columns-alphabetically">{{ t("settings.sortCompletionColumnsAlphabetically") }}</Label>
|
||||
<div class="grid gap-4 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
||||
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
|
||||
<div class="space-y-1">
|
||||
<Label for="editor-auto-close-brackets">{{ t("settings.autoCloseBrackets") }}</Label>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t("settings.autoCloseBracketsDescription") }}
|
||||
</p>
|
||||
</div>
|
||||
<Switch id="editor-auto-close-brackets" v-model="editAutoCloseBrackets" class="mt-0.5" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
|
||||
<div class="space-y-1">
|
||||
<Label for="editor-insert-space-after-completion">{{ t("settings.insertSpaceAfterCompletion") }}</Label>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t("settings.insertSpaceAfterCompletionDescription") }}
|
||||
</p>
|
||||
</div>
|
||||
<Switch id="editor-insert-space-after-completion" v-model="editInsertSpaceAfterCompletion" class="mt-0.5" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
|
||||
<div class="space-y-1">
|
||||
<Label for="editor-sort-completion-columns-alphabetically">{{ t("settings.sortCompletionColumnsAlphabetically") }}</Label>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t("settings.sortCompletionColumnsAlphabeticallyDescription") }}
|
||||
</p>
|
||||
</div>
|
||||
<Switch id="editor-sort-completion-columns-alphabetically" v-model="editSortCompletionColumnsAlphabetically" class="mt-0.5" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
|
||||
<div class="space-y-1">
|
||||
<Label for="editor-select-first-completion-on-open">{{ t("settings.selectFirstCompletionOnOpen") }}</Label>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t("settings.selectFirstCompletionOnOpenDescription") }}
|
||||
</p>
|
||||
</div>
|
||||
<Switch id="editor-select-first-completion-on-open" v-model="editSelectFirstCompletionOnOpen" class="mt-0.5" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
|
||||
<div class="space-y-1">
|
||||
<Label for="editor-auto-alias-tables">{{ t("settings.autoAliasTables") }}</Label>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t("settings.autoAliasTablesDescription") }}
|
||||
</p>
|
||||
</div>
|
||||
<Switch id="editor-auto-alias-tables" v-model="editAutoAliasTables" class="mt-0.5" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>{{ t("settings.completionTriggerMode") }}</Label>
|
||||
<Select :model-value="editCompletionTriggerMode" @update:model-value="onCompletionTriggerModeChange">
|
||||
<SelectTrigger>
|
||||
<SelectValue :placeholder="t('settings.completionTriggerMode')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="manual">{{ t("settings.completionTriggerModeManual") }}</SelectItem>
|
||||
<SelectItem value="require-prefix">{{ t("settings.completionTriggerModeRequirePrefix") }}</SelectItem>
|
||||
<SelectItem value="positional">{{ t("settings.completionTriggerModePositional") }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t("settings.sortCompletionColumnsAlphabeticallyDescription") }}
|
||||
{{ t("settings.completionTriggerModeDescription") }}
|
||||
</p>
|
||||
</div>
|
||||
<Switch id="editor-sort-completion-columns-alphabetically" v-model="editSortCompletionColumnsAlphabetically" class="mt-0.5" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
|
||||
<div class="space-y-1">
|
||||
<Label for="editor-auto-alias-tables">{{ t("settings.autoAliasTables") }}</Label>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t("settings.autoAliasTablesDescription") }}
|
||||
</p>
|
||||
</div>
|
||||
<Switch id="editor-auto-alias-tables" v-model="editAutoAliasTables" class="mt-0.5" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>{{ t("settings.completionTriggerMode") }}</Label>
|
||||
<Select :model-value="editCompletionTriggerMode" @update:model-value="onCompletionTriggerModeChange">
|
||||
<SelectTrigger>
|
||||
<SelectValue :placeholder="t('settings.completionTriggerMode')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="manual">{{ t("settings.completionTriggerModeManual") }}</SelectItem>
|
||||
<SelectItem value="require-prefix">{{ t("settings.completionTriggerModeRequirePrefix") }}</SelectItem>
|
||||
<SelectItem value="positional">{{ t("settings.completionTriggerModePositional") }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t("settings.completionTriggerModeDescription") }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2 md:col-span-2">
|
||||
<div class="min-w-0 space-y-1">
|
||||
|
||||
@@ -131,6 +131,7 @@ import { createShellLineCommentHighlight } from "@/lib/editor/codemirrorShellLin
|
||||
import { extendQueryEditorSelection, runQueryEditorAltExtendSelection } from "@/lib/editor/queryEditorExtendSelection";
|
||||
import { createQueryEditorStringMouseSelection } from "@/lib/editor/queryEditorStringMouseSelection";
|
||||
import { createQueryEditorCompletionShortcutBindings } from "@/lib/editor/queryEditorCompletionShortcut";
|
||||
import { acceptSelectedCompletionWithRetry, acceptSelectedOrFirstCompletion } from "@/lib/editor/queryEditorCompletionAcceptance";
|
||||
import type { StatementExecutionMarker } from "@/lib/tabs/tabPresentation";
|
||||
import { isSchemaAware, isSingleDatabase, supportsDatabaseNameCompletion, supportsDatabaseSchemaQualifier, supportsQueryEditorBlockComments, supportsSqlInListPaste } from "@/lib/database/databaseFeatureSupport";
|
||||
import { metadataSchemaForConnection, sqlSnippetDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
|
||||
@@ -202,6 +203,7 @@ const COMPLETION_REMOTE_LATENCY_BUDGET_MS = 120;
|
||||
const COMPLETION_DEBOUNCE_DELAY_MS = 150;
|
||||
const COMPLETION_TAB_RETRY_DELAY_MS = 16;
|
||||
const COMPLETION_TAB_MAX_WAIT_MS = COMPLETION_DEBOUNCE_DELAY_MS + COMPLETION_REMOTE_LATENCY_BUDGET_MS + 100;
|
||||
const COMPLETION_ENTER_MAX_WAIT_MS = 125;
|
||||
// Internal rollback switch: flip to false to route completion, diagnostics, and navigation through the legacy SQL context path.
|
||||
const SEMANTIC_SQL_COMPLETION_ENABLED = true;
|
||||
|
||||
@@ -554,6 +556,8 @@ let buildSqlSemanticHighlightExtension: (() => import("@codemirror/state").Exten
|
||||
let codeMirrorSnippetCompletion: typeof import("@codemirror/autocomplete").snippetCompletion;
|
||||
let codeMirrorCompletionStatus: typeof import("@codemirror/autocomplete").completionStatus | null = null;
|
||||
let codeMirrorAcceptCompletion: typeof import("@codemirror/autocomplete").acceptCompletion | null = null;
|
||||
let codeMirrorSelectedCompletionIndex: typeof import("@codemirror/autocomplete").selectedCompletionIndex | null = null;
|
||||
let codeMirrorSelectFirstCompletion: import("@codemirror/view").Command | null = null;
|
||||
let codeMirrorStartCompletion: typeof import("@codemirror/autocomplete").startCompletion | null = null;
|
||||
let codeMirrorCloseCompletion: typeof import("@codemirror/autocomplete").closeCompletion | null = null;
|
||||
let codeMirrorInsertCompletionText: typeof import("@codemirror/autocomplete").insertCompletionText | null = null;
|
||||
@@ -574,6 +578,7 @@ let codeMirrorToggleBlockComment: typeof import("@codemirror/commands").toggleBl
|
||||
let codeMirrorDefaultKeymap: readonly import("@codemirror/view").KeyBinding[] | null = null;
|
||||
let codeMirrorToggleFold: typeof import("@codemirror/language").toggleFold | null = null;
|
||||
let pendingCompletionTabTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let cancelPendingCompletionEnter: (() => void) | null = null;
|
||||
let setSqlDiagnosticsEffect: import("@codemirror/state").StateEffectType<SqlSemanticDiagnostic[]> | null = null;
|
||||
let setPreviewRangeEffect:
|
||||
| import("@codemirror/state").StateEffectType<{
|
||||
@@ -1925,7 +1930,7 @@ function runKeymapExtension(codeMirrorKeymap: (typeof import("@codemirror/view")
|
||||
codeMirrorKeymap.of([
|
||||
{
|
||||
key: "Enter",
|
||||
run: insertNewlineWithoutCompletion,
|
||||
run: handleEnter,
|
||||
},
|
||||
...binding(shortcuts.find, openSearch),
|
||||
...binding(shortcuts.replace, openReplace),
|
||||
@@ -1995,6 +2000,36 @@ function runKeymapExtension(codeMirrorKeymap: (typeof import("@codemirror/view")
|
||||
];
|
||||
}
|
||||
|
||||
function handleEnter(view: EditorViewType): boolean {
|
||||
clearPendingCompletionEnter();
|
||||
if (settingsStore.editorSettings.selectFirstCompletionOnOpen && codeMirrorCompletionStatus) {
|
||||
let cancelRetry: (() => void) | null = null;
|
||||
const result = acceptSelectedCompletionWithRetry(view, {
|
||||
completionStatus: codeMirrorCompletionStatus,
|
||||
acceptCompletion: codeMirrorAcceptCompletion,
|
||||
selectedCompletionIndex: codeMirrorSelectedCompletionIndex,
|
||||
selectFirstCompletion: codeMirrorSelectFirstCompletion,
|
||||
retryDelayMs: COMPLETION_TAB_RETRY_DELAY_MS,
|
||||
maxWaitMs: COMPLETION_ENTER_MAX_WAIT_MS,
|
||||
onUnavailable: () => insertNewlineWithoutCompletion(view),
|
||||
onSettled: () => {
|
||||
if (cancelPendingCompletionEnter === cancelRetry) cancelPendingCompletionEnter = null;
|
||||
},
|
||||
});
|
||||
if (result.handled) {
|
||||
cancelRetry = result.cancel ?? null;
|
||||
cancelPendingCompletionEnter = cancelRetry;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return insertNewlineWithoutCompletion(view);
|
||||
}
|
||||
|
||||
function clearPendingCompletionEnter() {
|
||||
cancelPendingCompletionEnter?.();
|
||||
cancelPendingCompletionEnter = null;
|
||||
}
|
||||
|
||||
function insertNewlineWithoutCompletion(view: EditorViewType): boolean {
|
||||
codeMirrorCloseCompletion?.(view);
|
||||
suppressNextSqlCompletionAutoStartUntil = Date.now() + 750;
|
||||
@@ -2018,7 +2053,7 @@ function acceptCompletionOrNextSnippetField(view: EditorViewType): boolean {
|
||||
// of the indent edit itself, so it must never hijack this or a following Tab.
|
||||
if (view.state.selection.ranges.every((range) => range.empty)) {
|
||||
const completionStatus = codeMirrorCompletionStatus?.(view.state) ?? null;
|
||||
if (completionStatus === "active" && (codeMirrorAcceptCompletion?.(view) ?? false)) return true;
|
||||
if (completionStatus === "active" && acceptSelectedOrFirstCompletion(view, codeMirrorAcceptCompletion, codeMirrorSelectedCompletionIndex, codeMirrorSelectFirstCompletion)) return true;
|
||||
if (completionStatus) return waitForCompletionTab(view);
|
||||
}
|
||||
return codeMirrorNextSnippetField?.(view) ?? false;
|
||||
@@ -2042,7 +2077,7 @@ function waitForCompletionTab(view: EditorViewType): boolean {
|
||||
if (view.state.doc !== initialDoc || selectionRanges.length !== initialSelectionRanges.length || selectionRanges.some((range, index) => !range.empty || range.anchor !== initialSelectionRanges[index]?.anchor || range.head !== initialSelectionRanges[index]?.head)) return;
|
||||
|
||||
const completionStatus = codeMirrorCompletionStatus?.(view.state) ?? null;
|
||||
if (completionStatus === "active" && (codeMirrorAcceptCompletion?.(view) ?? false)) return;
|
||||
if (completionStatus === "active" && acceptSelectedOrFirstCompletion(view, codeMirrorAcceptCompletion, codeMirrorSelectedCompletionIndex, codeMirrorSelectFirstCompletion)) return;
|
||||
if (completionStatus && Date.now() - startedAt < COMPLETION_TAB_MAX_WAIT_MS) {
|
||||
pendingCompletionTabTimer = setTimeout(retry, COMPLETION_TAB_RETRY_DELAY_MS);
|
||||
return;
|
||||
@@ -4661,7 +4696,7 @@ onMounted(async () => {
|
||||
},
|
||||
{ EditorState, EditorSelection, Compartment, Prec, RangeSet, StateEffect, StateField },
|
||||
langSql,
|
||||
{ autocompletion, startCompletion, acceptCompletion, closeBrackets, closeBracketsKeymap, snippetCompletion, completionStatus, completionKeymap, insertCompletionText, nextSnippetField, closeCompletion },
|
||||
{ autocompletion, startCompletion, acceptCompletion, closeBrackets, closeBracketsKeymap, snippetCompletion, completionStatus, completionKeymap, insertCompletionText, nextSnippetField, closeCompletion, moveCompletionSelection, selectedCompletionIndex },
|
||||
{ copyLineDown, copyLineUp, deleteLine, indentLess, indentMore, insertNewlineKeepIndent, moveLineDown, moveLineUp, redo, selectAll, undo, toggleLineComment, toggleBlockComment, history, defaultKeymap, historyKeymap },
|
||||
{ bracketMatching, foldGutter, indentOnInput, indentUnit, syntaxHighlighting, defaultHighlightStyle, foldKeymap, toggleFold, ensureSyntaxTree, highlightingFor, syntaxTree },
|
||||
{ searchKeymap },
|
||||
@@ -4698,6 +4733,8 @@ onMounted(async () => {
|
||||
setSqlDiagnosticsEffect = StateEffect.define<SqlSemanticDiagnostic[]>();
|
||||
codeMirrorCompletionStatus = completionStatus;
|
||||
codeMirrorAcceptCompletion = acceptCompletion;
|
||||
codeMirrorSelectedCompletionIndex = selectedCompletionIndex;
|
||||
codeMirrorSelectFirstCompletion = moveCompletionSelection(true);
|
||||
codeMirrorCloseCompletion = closeCompletion;
|
||||
codeMirrorStartCompletion = startCompletion;
|
||||
codeMirrorInsertCompletionText = insertCompletionText;
|
||||
@@ -4931,7 +4968,7 @@ onMounted(async () => {
|
||||
buildSqlCompletionExtension = () =>
|
||||
autocompletion({
|
||||
activateOnTyping: true,
|
||||
selectOnOpen: false,
|
||||
selectOnOpen: settingsStore.editorSettings.selectFirstCompletionOnOpen,
|
||||
compareCompletions: (a, b) => compareSqlCompletions(a, b, settingsStore.editorSettings.sortCompletionColumnsAlphabetically),
|
||||
override: [async (context: CompletionContext) => provideSqlCompletions(context)],
|
||||
});
|
||||
@@ -5841,7 +5878,7 @@ watch(
|
||||
);
|
||||
|
||||
watch(
|
||||
() => [settingsStore.editorSettings.snippets, settingsStore.editorSettings.sortCompletionColumnsAlphabetically],
|
||||
() => [settingsStore.editorSettings.snippets, settingsStore.editorSettings.sortCompletionColumnsAlphabetically, settingsStore.editorSettings.selectFirstCompletionOnOpen],
|
||||
() => {
|
||||
completionEpoch++;
|
||||
if (!view.value || !completionComp || !buildSqlCompletionExtension) return;
|
||||
@@ -5879,6 +5916,7 @@ function pauseQueryEditorBackgroundWork() {
|
||||
flushEditorViewport();
|
||||
flushEditorSelection();
|
||||
clearTableNavigationHover();
|
||||
clearPendingCompletionEnter();
|
||||
clearPendingCompletionTab();
|
||||
executionViewportOwnership.reset();
|
||||
editorIsActive = false;
|
||||
|
||||
@@ -6429,10 +6429,13 @@ export default {
|
||||
vimModeDescription: "Use Vim-style modal editing in the SQL editor",
|
||||
autoCloseBrackets: "Auto-close brackets",
|
||||
autoCloseBracketsDescription: "Automatically insert closing brackets and quotes when typing an opening one",
|
||||
sqlCompletionSection: "SQL completion",
|
||||
insertSpaceAfterCompletion: "Insert a space after completion",
|
||||
insertSpaceAfterCompletionDescription: "Append a space after accepting a keyword, table, or column completion when the next character allows it",
|
||||
sortCompletionColumnsAlphabetically: "Sort completion columns alphabetically",
|
||||
sortCompletionColumnsAlphabeticallyDescription: "Turn off to keep table fields in their original database order",
|
||||
selectFirstCompletionOnOpen: "Automatically select the first completion",
|
||||
selectFirstCompletionOnOpenDescription: "When enabled, the first candidate is selected when the list opens, and Enter or Tab accepts it. When disabled, Enter inserts a new line and Tab accepts the first candidate.",
|
||||
completionTriggerMode: "Auto-completion trigger mode",
|
||||
completionTriggerModeDescription: "Controls when the SQL completion popup opens automatically. Manual requires a shortcut key; Typed requires a typed identifier prefix; Smart preserves the current behavior.",
|
||||
completionTriggerModeManual: "Manual only",
|
||||
|
||||
@@ -6115,6 +6115,9 @@ export default withEnglishFallback({
|
||||
vimModeDescription: "Usar edición modal estilo Vim en el editor SQL",
|
||||
autoCloseBrackets: "Cerrar paréntesis automáticamente",
|
||||
autoCloseBracketsDescription: "Insertar automáticamente paréntesis y comillas de cierre al escribir los de apertura",
|
||||
sqlCompletionSection: "Autocompletado SQL",
|
||||
selectFirstCompletionOnOpen: "Seleccionar automáticamente la primera sugerencia",
|
||||
selectFirstCompletionOnOpenDescription: "Al activarlo, se selecciona la primera sugerencia al abrir la lista y se acepta con Enter o Tab. Al desactivarlo, Enter inserta una nueva línea y Tab acepta la primera sugerencia.",
|
||||
completionTriggerMode: "Modo de activación de autocompletado",
|
||||
completionTriggerModeDescription: "Controla cuándo se abre automáticamente la ventana de autocompletado SQL. Manual requiere una tecla de acceso rápido; Prefijo escrito requiere un identificador escrito; Inteligente conserva el comportamiento actual.",
|
||||
completionTriggerModeManual: "Solo manual",
|
||||
|
||||
@@ -6115,6 +6115,9 @@ export default withEnglishFallback({
|
||||
vimModeDescription: "Usa la modifica modale in stile Vim nell'editor SQL",
|
||||
autoCloseBrackets: "Chiusura automatica parentesi",
|
||||
autoCloseBracketsDescription: "Inserisci automaticamente parentesi e virgolette di chiusura quando digiti quelle di apertura",
|
||||
sqlCompletionSection: "Completamento SQL",
|
||||
selectFirstCompletionOnOpen: "Seleziona automaticamente il primo suggerimento",
|
||||
selectFirstCompletionOnOpenDescription: "Se attivo, il primo suggerimento viene selezionato all'apertura dell'elenco e accettato con Invio o Tab. Se disattivo, Invio inserisce una nuova riga e Tab accetta il primo suggerimento.",
|
||||
completionTriggerMode: "Modalità di attivazione autocompletamento",
|
||||
completionTriggerModeDescription: "Controlla quando si apre automaticamente il popup di autocompletamento SQL. Manuale richiede un tasto di scelta rapida; Prefisso digitato richiede un identificatore digitato; Intelligente mantiene il comportamento attuale.",
|
||||
completionTriggerModeManual: "Solo manuale",
|
||||
|
||||
@@ -6518,8 +6518,11 @@ export default withEnglishFallback({
|
||||
syncSnippetRetryLegacyCleanup: "旧スニペットのクリーンアップを再試行",
|
||||
syncSnippetLegacyCleanupSuccess: "旧プレーンテキストスニペット {id} を削除しました。",
|
||||
syncSnippetGuide: "設定ガイド",
|
||||
sqlCompletionSection: "SQL 補完",
|
||||
insertSpaceAfterCompletion: "補全後に自動でスペースを挿入",
|
||||
insertSpaceAfterCompletionDescription: "キーワード、テーブル名、列名の補全確定時、次の文字が許容する場合に自動でスペースを補填します",
|
||||
selectFirstCompletionOnOpen: "最初の補完候補を自動選択",
|
||||
selectFirstCompletionOnOpenDescription: "有効にすると、候補一覧を開いた時に先頭項目を選択し、Enter または Tab で確定します。無効にすると、Enter は改行し、Tab で先頭候補を確定します。",
|
||||
completionTriggerMode: "自動補完トリガーモード",
|
||||
completionTriggerModeDescription: "SQL補完ポップアップが自動的に開くタイミングを制御します。手動はショートカットキーが必要; 識別子入力後は1文字以上の入力が必要; スマート位置は現在の動作を維持します。",
|
||||
completionTriggerModeManual: "手動のみ",
|
||||
|
||||
@@ -5816,8 +5816,11 @@ export default withEnglishFallback({
|
||||
vimModeDescription: "SQL 편집기에서 Vim 스타일의 모달 편집을 사용합니다",
|
||||
autoCloseBrackets: "괄호 자동 닫기",
|
||||
autoCloseBracketsDescription: "여는 괄호나 따옴표를 입력할 때 닫는 괄호와 따옴표를 자동으로 삽입합니다",
|
||||
sqlCompletionSection: "SQL 자동 완성",
|
||||
insertSpaceAfterCompletion: "완성 후 공백 삽입",
|
||||
insertSpaceAfterCompletionDescription: "다음 문자가 허용할 때 키워드, 테이블 또는 컬럼 완성을 수락한 후 공백을 추가합니다",
|
||||
selectFirstCompletionOnOpen: "첫 번째 자동 완성 후보 자동 선택",
|
||||
selectFirstCompletionOnOpenDescription: "활성화하면 후보 목록이 열릴 때 첫 항목을 선택하고 Enter 또는 Tab으로 적용합니다. 비활성화하면 Enter는 줄을 바꾸고 Tab은 첫 후보를 적용합니다.",
|
||||
completionTriggerMode: "자동 완성 트리거 모드",
|
||||
completionTriggerModeDescription: "SQL 완성 팝업이 자동으로 열리는 시기를 제어합니다. 수동은 단축키 필요; 입력 식별자는 하나 이상의 문자 입력 필요; 스마트 위치는 현재 동작 유지.",
|
||||
completionTriggerModeManual: "수동만",
|
||||
|
||||
@@ -6117,6 +6117,9 @@ export default withEnglishFallback({
|
||||
vimModeDescription: "Usar edição modal no estilo Vim no editor SQL",
|
||||
autoCloseBrackets: "Fechar parênteses automaticamente",
|
||||
autoCloseBracketsDescription: "Inserir automaticamente parênteses e aspas de fechamento ao digitar os de abertura",
|
||||
sqlCompletionSection: "Preenchimento de SQL",
|
||||
selectFirstCompletionOnOpen: "Selecionar automaticamente a primeira sugestão",
|
||||
selectFirstCompletionOnOpenDescription: "Quando ativado, a primeira sugestão é selecionada ao abrir a lista e aceita com Enter ou Tab. Quando desativado, Enter insere uma nova linha e Tab aceita a primeira sugestão.",
|
||||
completionTriggerMode: "Modo de acionamento do autocompletar",
|
||||
completionTriggerModeDescription: "Controla quando o popup de autocompletar SQL abre automaticamente. Manual requer uma tecla de atalho; Prefixo digitado requer um identificador digitado; Inteligente mantém o comportamento atual.",
|
||||
completionTriggerModeManual: "Apenas manual",
|
||||
|
||||
@@ -6412,10 +6412,13 @@ export default withEnglishFallback({
|
||||
vimModeDescription: "在 SQL 编辑器中使用 Vim 风格的模态编辑",
|
||||
autoCloseBrackets: "自动成对补全",
|
||||
autoCloseBracketsDescription: "输入左括号或左引号时自动补全对应的右括号或右引号",
|
||||
sqlCompletionSection: "SQL 补全",
|
||||
insertSpaceAfterCompletion: "补全后自动添加空格",
|
||||
insertSpaceAfterCompletionDescription: "选择关键字、表名或列名补全后,在后续字符允许时自动追加空格",
|
||||
sortCompletionColumnsAlphabetically: "按字母顺序排列补全字段",
|
||||
sortCompletionColumnsAlphabeticallyDescription: "关闭后,表字段将保持数据库中的原始顺序",
|
||||
selectFirstCompletionOnOpen: "自动选中首个补全候选",
|
||||
selectFirstCompletionOnOpenDescription: "开启后,候选列表弹出时会选中第一项,可用 Enter 或 Tab 接受;关闭后,Enter 换行,Tab 接受第一项。",
|
||||
completionTriggerMode: "自动补全触发方式",
|
||||
completionTriggerModeDescription: "控制 SQL 补全弹窗何时自动打开。仅手动提示需快捷键;输入标识符后提示需输入至少一个字符;智能位置提示保持当前行为。",
|
||||
completionTriggerModeManual: "仅手动提示",
|
||||
|
||||
@@ -5433,6 +5433,9 @@ export default withEnglishFallback({
|
||||
vimModeDescription: "在 SQL 編輯器中使用 Vim 風格的模態編輯",
|
||||
autoCloseBrackets: "自動成對補全",
|
||||
autoCloseBracketsDescription: "輸入左括號或左引號時自動補全對應的右括號或右引號",
|
||||
sqlCompletionSection: "SQL 補全",
|
||||
selectFirstCompletionOnOpen: "自動選取第一個補全候選",
|
||||
selectFirstCompletionOnOpenDescription: "開啟後,候選清單彈出時會選取第一項,可用 Enter 或 Tab 接受;關閉後,Enter 換行,Tab 接受第一項。",
|
||||
completionTriggerMode: "自動補全觸發方式",
|
||||
completionTriggerModeDescription: "控制 SQL 補全彈窗何時自動開啟。僅手動提示需快捷鍵;輸入識別碼後提示需輸入至少一個字元;智慧位置提示保持目前行為。",
|
||||
completionTriggerModeManual: "僅手動提示",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import ts from "typescript";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { acceptSelectedCompletionWithRetry, acceptSelectedOrFirstCompletion } from "@/lib/editor/queryEditorCompletionAcceptance";
|
||||
import { DEFAULT_SHORTCUT_SETTINGS, normalizeShortcutSettings, shortcutToCodeMirrorKey } from "@/lib/editor/shortcutRegistry";
|
||||
|
||||
const queryEditorSource = readFileSync(new URL("../../../components/editor/QueryEditor.vue", import.meta.url), "utf8");
|
||||
@@ -47,6 +48,7 @@ interface MockView {
|
||||
|
||||
interface TabHarness {
|
||||
handleTab: (view: MockView) => boolean;
|
||||
handleEnter: (view: MockView) => boolean;
|
||||
insertNewlineWithoutCompletion: (view: MockView) => boolean;
|
||||
acceptCompletionOrNextSnippetField: (view: MockView) => boolean;
|
||||
clearPendingCompletionTab: () => void;
|
||||
@@ -56,24 +58,31 @@ interface TabHarness {
|
||||
function createHarness(options: {
|
||||
completionStatus: (state: MockState) => "active" | "pending" | null;
|
||||
acceptCompletion?: (view: MockView) => boolean;
|
||||
selectedCompletionIndex?: (state: MockState) => number | null;
|
||||
selectFirstCompletion?: (view: MockView) => boolean;
|
||||
closeCompletion?: (view: MockView) => boolean;
|
||||
insertNewlineKeepIndent?: (view: MockView) => boolean;
|
||||
nextSnippetField?: (view: MockView) => boolean;
|
||||
indentMore?: (view: MockView) => boolean;
|
||||
acceptCompletionShortcut?: string;
|
||||
selectFirstCompletionOnOpen?: boolean;
|
||||
}): TabHarness {
|
||||
const source = [
|
||||
extractDeclaration(/const COMPLETION_REMOTE_LATENCY_BUDGET_MS = \d+;/, "remote completion latency budget"),
|
||||
extractDeclaration(/const COMPLETION_DEBOUNCE_DELAY_MS = \d+;/, "completion debounce delay"),
|
||||
extractDeclaration(/const COMPLETION_TAB_RETRY_DELAY_MS = \d+;/, "completion retry delay"),
|
||||
extractDeclaration(/const COMPLETION_TAB_MAX_WAIT_MS = [^;]+;/, "completion wait timeout"),
|
||||
extractDeclaration(/const COMPLETION_ENTER_MAX_WAIT_MS = \d+;/, "completion Enter wait timeout"),
|
||||
"let pendingCompletionTabTimer: ReturnType<typeof setTimeout> | null = null;",
|
||||
"let cancelPendingCompletionEnter: (() => void) | null = null;",
|
||||
"let suppressNextSqlCompletionAutoStartUntil = 0;",
|
||||
extractFunction("editorIndentUnit"),
|
||||
extractFunction("handleTab"),
|
||||
extractFunction("tabKeyAcceptsCompletion"),
|
||||
extractFunction("handleTabWithoutAcceptingCompletion"),
|
||||
extractFunction("performNormalTab"),
|
||||
extractFunction("handleEnter"),
|
||||
extractFunction("clearPendingCompletionEnter"),
|
||||
extractFunction("insertNewlineWithoutCompletion"),
|
||||
extractFunction("acceptCompletionOrNextSnippetField"),
|
||||
extractFunction("clearPendingCompletionTab"),
|
||||
@@ -86,6 +95,10 @@ function createHarness(options: {
|
||||
const factory = new Function(
|
||||
"codeMirrorCompletionStatus",
|
||||
"codeMirrorAcceptCompletion",
|
||||
"codeMirrorSelectedCompletionIndex",
|
||||
"codeMirrorSelectFirstCompletion",
|
||||
"acceptSelectedCompletionWithRetry",
|
||||
"acceptSelectedOrFirstCompletion",
|
||||
"codeMirrorCloseCompletion",
|
||||
"codeMirrorInsertNewlineKeepIndent",
|
||||
"insertQueryEditorNewline",
|
||||
@@ -95,11 +108,15 @@ function createHarness(options: {
|
||||
"normalizeShortcutSettings",
|
||||
"shortcutToCodeMirrorKey",
|
||||
"props",
|
||||
`${javascript}\nreturn { handleTab, insertNewlineWithoutCompletion, acceptCompletionOrNextSnippetField, clearPendingCompletionTab, consumeSqlCompletionAutoStartSuppression };`,
|
||||
`${javascript}\nreturn { handleTab, handleEnter, insertNewlineWithoutCompletion, acceptCompletionOrNextSnippetField, clearPendingCompletionTab, consumeSqlCompletionAutoStartSuppression };`,
|
||||
);
|
||||
return factory(
|
||||
options.completionStatus,
|
||||
options.acceptCompletion ?? (() => false),
|
||||
options.selectedCompletionIndex ?? (() => 0),
|
||||
options.selectFirstCompletion ?? (() => false),
|
||||
acceptSelectedCompletionWithRetry,
|
||||
acceptSelectedOrFirstCompletion,
|
||||
options.closeCompletion ?? (() => false),
|
||||
options.insertNewlineKeepIndent ?? (() => false),
|
||||
(view: MockView, fallback: ((view: MockView) => boolean) | null | undefined) => fallback?.(view) ?? false,
|
||||
@@ -109,6 +126,7 @@ function createHarness(options: {
|
||||
editorSettings: {
|
||||
sqlFormatter: { useTabs: false, tabWidth: 2 },
|
||||
shortcuts: { ...DEFAULT_SHORTCUT_SETTINGS, acceptCompletion: options.acceptCompletionShortcut ?? DEFAULT_SHORTCUT_SETTINGS.acceptCompletion },
|
||||
selectFirstCompletionOnOpen: options.selectFirstCompletionOnOpen ?? false,
|
||||
},
|
||||
},
|
||||
normalizeShortcutSettings,
|
||||
@@ -156,6 +174,19 @@ describe("QueryEditor completion Tab keymap", () => {
|
||||
expect(harness.consumeSqlCompletionAutoStartSuppression()).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts the selected completion on Enter only in first-option mode", () => {
|
||||
const acceptCompletion = vi.fn(() => true);
|
||||
const closeCompletion = vi.fn(() => true);
|
||||
const insertNewlineKeepIndent = vi.fn(() => true);
|
||||
const harness = createHarness({ completionStatus: () => "active", acceptCompletion, closeCompletion, insertNewlineKeepIndent, selectFirstCompletionOnOpen: true });
|
||||
const view = createView();
|
||||
|
||||
expect(harness.handleEnter(view)).toBe(true);
|
||||
expect(acceptCompletion).toHaveBeenCalledWith(view);
|
||||
expect(closeCompletion).not.toHaveBeenCalled();
|
||||
expect(insertNewlineKeepIndent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not suppress later completion when Enter cannot insert a newline", () => {
|
||||
const harness = createHarness({ completionStatus: () => null, insertNewlineKeepIndent: () => false });
|
||||
|
||||
|
||||
+93
-16
@@ -2,14 +2,16 @@
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { acceptCompletion, autocompletion, completionStatus, currentCompletions, selectedCompletionIndex, startCompletion } from "@codemirror/autocomplete";
|
||||
import { EditorState, Transaction } from "@codemirror/state";
|
||||
import { acceptCompletion, autocompletion, completionStatus, currentCompletions, moveCompletionSelection, selectedCompletionIndex, startCompletion } from "@codemirror/autocomplete";
|
||||
import { EditorState, Prec, Transaction } from "@codemirror/state";
|
||||
import { insertNewlineKeepIndent } from "@codemirror/commands";
|
||||
import { EditorView, keymap, runScopeHandlers } from "@codemirror/view";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { acceptSelectedCompletionWithRetry, acceptSelectedOrFirstCompletion } from "@/lib/editor/queryEditorCompletionAcceptance";
|
||||
|
||||
const queryEditorSource = readFileSync(resolve(process.cwd(), "apps/desktop/src/components/editor/QueryEditor.vue"), "utf8");
|
||||
|
||||
function createCompletionView(doc = "") {
|
||||
function createCompletionView(doc = "", selectFirstCompletionOnOpen = false, interactionDelay: number | null = 0) {
|
||||
return new EditorView({
|
||||
parent: document.createElement("div"),
|
||||
state: EditorState.create({
|
||||
@@ -19,11 +21,36 @@ function createCompletionView(doc = "") {
|
||||
autocompletion({
|
||||
activateOnTyping: true,
|
||||
activateOnTypingDelay: 0,
|
||||
interactionDelay: 0,
|
||||
selectOnOpen: false,
|
||||
...(interactionDelay === null ? {} : { interactionDelay }),
|
||||
selectOnOpen: selectFirstCompletionOnOpen,
|
||||
override: [() => ({ from: 0, options: [{ label: "select" }, { label: "set" }] })],
|
||||
}),
|
||||
keymap.of([{ key: "Tab", run: acceptCompletion }]),
|
||||
Prec.highest(
|
||||
keymap.of([
|
||||
{
|
||||
key: "Tab",
|
||||
run: (view) => acceptSelectedOrFirstCompletion(view, acceptCompletion, selectedCompletionIndex, moveCompletionSelection(true)),
|
||||
},
|
||||
{
|
||||
key: "Enter",
|
||||
run: (view) => {
|
||||
if (selectFirstCompletionOnOpen) {
|
||||
const result = acceptSelectedCompletionWithRetry(view, {
|
||||
completionStatus,
|
||||
acceptCompletion,
|
||||
selectedCompletionIndex,
|
||||
selectFirstCompletion: moveCompletionSelection(true),
|
||||
retryDelayMs: 16,
|
||||
maxWaitMs: 125,
|
||||
onUnavailable: () => insertNewlineKeepIndent(view),
|
||||
});
|
||||
if (result.handled) return true;
|
||||
}
|
||||
return insertNewlineKeepIndent(view);
|
||||
},
|
||||
},
|
||||
]),
|
||||
),
|
||||
],
|
||||
}),
|
||||
});
|
||||
@@ -41,34 +68,84 @@ async function expectCompletionOpenWithoutSelection(view: EditorView) {
|
||||
|
||||
describe("QueryEditor completion selection", () => {
|
||||
it("binds SQL completion to CodeMirror's unselected-on-open mode", () => {
|
||||
expect(queryEditorSource).toMatch(/buildSqlCompletionExtension = \(\) =>\s+autocompletion\(\{\s+activateOnTyping: true,\s+selectOnOpen: false,/);
|
||||
expect(queryEditorSource).toMatch(/selectOnOpen: settingsStore\.editorSettings\.selectFirstCompletionOnOpen/);
|
||||
});
|
||||
|
||||
it("opens manual completion unselected until ArrowDown selects the first option", async () => {
|
||||
it("opens manual completion unselected and accepts the first option with Tab", async () => {
|
||||
const view = createCompletionView();
|
||||
|
||||
expect(startCompletion(view)).toBe(true);
|
||||
await expectCompletionOpenWithoutSelection(view);
|
||||
expect(press(view, "Tab")).toBe(false);
|
||||
expect(view.state.doc.toString()).toBe("");
|
||||
|
||||
expect(press(view, "ArrowDown")).toBe(true);
|
||||
expect(selectedCompletionIndex(view.state)).toBe(0);
|
||||
expect(press(view, "Tab")).toBe(true);
|
||||
expect(view.state.doc.toString()).toBe("select");
|
||||
view.destroy();
|
||||
});
|
||||
|
||||
it("accepts an explicitly selected option instead of resetting to the first option", async () => {
|
||||
const view = createCompletionView();
|
||||
|
||||
expect(startCompletion(view)).toBe(true);
|
||||
await expectCompletionOpenWithoutSelection(view);
|
||||
expect(press(view, "ArrowUp")).toBe(true);
|
||||
expect(selectedCompletionIndex(view.state)).toBe(1);
|
||||
expect(press(view, "Tab")).toBe(true);
|
||||
expect(view.state.doc.toString()).toBe("set");
|
||||
view.destroy();
|
||||
});
|
||||
|
||||
it("opens typing-triggered completion without selecting the first option", async () => {
|
||||
const view = createCompletionView();
|
||||
|
||||
view.dispatch({
|
||||
changes: { from: 0, insert: "s" },
|
||||
selection: { anchor: 1 },
|
||||
annotations: Transaction.userEvent.of("input.type"),
|
||||
});
|
||||
await expectCompletionOpenWithoutSelection(view);
|
||||
expect(press(view, "Tab")).toBe(false);
|
||||
expect(view.state.doc.toString()).toBe("s");
|
||||
expect(press(view, "Tab")).toBe(true);
|
||||
expect(view.state.doc.toString()).toBe("select");
|
||||
view.destroy();
|
||||
});
|
||||
|
||||
it("keeps Enter as a newline while no completion option is selected", async () => {
|
||||
const view = createCompletionView();
|
||||
|
||||
expect(startCompletion(view)).toBe(true);
|
||||
await expectCompletionOpenWithoutSelection(view);
|
||||
expect(press(view, "Enter")).toBe(true);
|
||||
expect(view.state.doc.toString()).toBe("\n");
|
||||
view.destroy();
|
||||
});
|
||||
|
||||
it("restores first-option selection and Enter acceptance when explicitly enabled", async () => {
|
||||
const view = createCompletionView("", true);
|
||||
|
||||
expect(startCompletion(view)).toBe(true);
|
||||
await expect.poll(() => completionStatus(view.state)).toBe("active");
|
||||
expect(selectedCompletionIndex(view.state)).toBe(0);
|
||||
expect(press(view, "Enter")).toBe(true);
|
||||
expect(view.state.doc.toString()).toBe("select");
|
||||
view.destroy();
|
||||
});
|
||||
|
||||
it("waits through CodeMirror's default interaction delay before accepting Enter", async () => {
|
||||
let now = 0;
|
||||
const dateNow = vi.spyOn(Date, "now").mockImplementation(() => now);
|
||||
const view = createCompletionView("", true, null);
|
||||
|
||||
try {
|
||||
expect(startCompletion(view)).toBe(true);
|
||||
await expect.poll(() => completionStatus(view.state), { interval: 1 }).toBe("active");
|
||||
expect(selectedCompletionIndex(view.state)).toBe(0);
|
||||
expect(acceptCompletion(view)).toBe(false);
|
||||
expect(press(view, "Enter")).toBe(true);
|
||||
expect(view.state.doc.toString()).toBe("");
|
||||
|
||||
now = 100;
|
||||
await expect.poll(() => view.state.doc.toString(), { interval: 10 }).toBe("select");
|
||||
} finally {
|
||||
dateNow.mockRestore();
|
||||
view.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { EditorState } from "@codemirror/state";
|
||||
import type { Command, EditorView } from "@codemirror/view";
|
||||
|
||||
type SelectedCompletionIndex = (state: EditorState) => number | null;
|
||||
type CompletionStatus = (state: EditorState) => "active" | "pending" | null;
|
||||
|
||||
interface RetryCompletionAcceptanceOptions {
|
||||
completionStatus: CompletionStatus;
|
||||
acceptCompletion: Command | null;
|
||||
selectedCompletionIndex: SelectedCompletionIndex | null;
|
||||
selectFirstCompletion: Command | null;
|
||||
retryDelayMs: number;
|
||||
maxWaitMs: number;
|
||||
onUnavailable: () => void;
|
||||
onSettled?: () => void;
|
||||
}
|
||||
|
||||
interface CompletionAcceptanceAttempt {
|
||||
handled: boolean;
|
||||
cancel?: () => void;
|
||||
}
|
||||
|
||||
export function acceptSelectedOrFirstCompletion(view: EditorView, acceptCompletion: Command | null, selectedCompletionIndex: SelectedCompletionIndex | null, selectFirstCompletion: Command | null): boolean {
|
||||
if (!acceptCompletion) return false;
|
||||
if (!selectedCompletionIndex || !selectFirstCompletion) return acceptCompletion(view);
|
||||
if (selectedCompletionIndex(view.state) !== null) return acceptCompletion(view);
|
||||
if (!selectFirstCompletion(view)) return false;
|
||||
return acceptCompletion(view);
|
||||
}
|
||||
|
||||
export function acceptSelectedCompletionWithRetry(view: EditorView, options: RetryCompletionAcceptanceOptions): CompletionAcceptanceAttempt {
|
||||
if (options.completionStatus(view.state) !== "active") return { handled: false };
|
||||
if (acceptSelectedOrFirstCompletion(view, options.acceptCompletion, options.selectedCompletionIndex, options.selectFirstCompletion)) return { handled: true };
|
||||
|
||||
const initialDoc = view.state.doc;
|
||||
const initialSelectionRanges = view.state.selection.ranges.map((range) => ({ anchor: range.anchor, head: range.head }));
|
||||
const startedAt = Date.now();
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let settled = false;
|
||||
|
||||
const settle = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timer !== null) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
options.onSettled?.();
|
||||
};
|
||||
|
||||
const retry = () => {
|
||||
timer = null;
|
||||
if (settled) return;
|
||||
|
||||
const selectionRanges = view.state.selection.ranges;
|
||||
if (view.state.doc !== initialDoc || selectionRanges.length !== initialSelectionRanges.length || selectionRanges.some((range, index) => range.anchor !== initialSelectionRanges[index]?.anchor || range.head !== initialSelectionRanges[index]?.head)) {
|
||||
settle();
|
||||
return;
|
||||
}
|
||||
|
||||
const completionStatus = options.completionStatus(view.state);
|
||||
if (completionStatus === "active" && acceptSelectedOrFirstCompletion(view, options.acceptCompletion, options.selectedCompletionIndex, options.selectFirstCompletion)) {
|
||||
settle();
|
||||
return;
|
||||
}
|
||||
if (completionStatus && Date.now() - startedAt < options.maxWaitMs) {
|
||||
timer = setTimeout(retry, options.retryDelayMs);
|
||||
return;
|
||||
}
|
||||
|
||||
settle();
|
||||
options.onUnavailable();
|
||||
};
|
||||
|
||||
timer = setTimeout(retry, options.retryDelayMs);
|
||||
return { handled: true, cancel: settle };
|
||||
}
|
||||
@@ -22,6 +22,7 @@ export const EDITOR_SETTINGS_DRAFT_KEYS = [
|
||||
"autoAliasTables",
|
||||
"insertSpaceAfterCompletion",
|
||||
"sortCompletionColumnsAlphabetically",
|
||||
"selectFirstCompletionOnOpen",
|
||||
"wordWrap",
|
||||
"vimModeEnabled",
|
||||
"autoCloseBrackets",
|
||||
|
||||
@@ -132,6 +132,13 @@ describe("normalizeEditorSettings", () => {
|
||||
expect(normalizeEditorSettings({ insertSpaceAfterCompletion: false }).insertSpaceAfterCompletion).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps non-intrusive completion selection as the default", () => {
|
||||
expect(normalizeEditorSettings({}).selectFirstCompletionOnOpen).toBe(false);
|
||||
expect(normalizeEditorSettings({ selectFirstCompletionOnOpen: true }).selectFirstCompletionOnOpen).toBe(true);
|
||||
expect(normalizeEditorSettings({ selectFirstCompletionOnOpen: false }).selectFirstCompletionOnOpen).toBe(false);
|
||||
expect(normalizeEditorSettings({ selectFirstCompletionOnOpen: "true" } as any).selectFirstCompletionOnOpen).toBe(false);
|
||||
});
|
||||
|
||||
it("defaults sidebar connection sorting to manual order and preserves valid alphabetical modes", () => {
|
||||
expect(normalizeEditorSettings({}).sidebarConnectionSortMode).toBe("manual");
|
||||
expect(normalizeEditorSettings({ sidebarConnectionSortMode: "asc" }).sidebarConnectionSortMode).toBe("asc");
|
||||
|
||||
@@ -531,6 +531,7 @@ export interface EditorSettings {
|
||||
autoAliasTables: boolean;
|
||||
insertSpaceAfterCompletion: boolean;
|
||||
sortCompletionColumnsAlphabetically: boolean;
|
||||
selectFirstCompletionOnOpen: boolean;
|
||||
wordWrap: boolean;
|
||||
tableDdlWordWrap: boolean;
|
||||
vimModeEnabled: boolean;
|
||||
@@ -742,6 +743,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = {
|
||||
autoAliasTables: true,
|
||||
insertSpaceAfterCompletion: true,
|
||||
sortCompletionColumnsAlphabetically: true,
|
||||
selectFirstCompletionOnOpen: false,
|
||||
wordWrap: false,
|
||||
tableDdlWordWrap: true,
|
||||
vimModeEnabled: false,
|
||||
@@ -1117,6 +1119,7 @@ export function normalizeEditorSettings(settings: Partial<EditorSettings>, exist
|
||||
autoAliasTables: settings.autoAliasTables ?? DEFAULT_EDITOR_SETTINGS.autoAliasTables,
|
||||
insertSpaceAfterCompletion: typeof settings.insertSpaceAfterCompletion === "boolean" ? settings.insertSpaceAfterCompletion : DEFAULT_EDITOR_SETTINGS.insertSpaceAfterCompletion,
|
||||
sortCompletionColumnsAlphabetically: typeof settings.sortCompletionColumnsAlphabetically === "boolean" ? settings.sortCompletionColumnsAlphabetically : DEFAULT_EDITOR_SETTINGS.sortCompletionColumnsAlphabetically,
|
||||
selectFirstCompletionOnOpen: typeof settings.selectFirstCompletionOnOpen === "boolean" ? settings.selectFirstCompletionOnOpen : DEFAULT_EDITOR_SETTINGS.selectFirstCompletionOnOpen,
|
||||
wordWrap: settings.wordWrap ?? DEFAULT_EDITOR_SETTINGS.wordWrap,
|
||||
tableDdlWordWrap: typeof settings.tableDdlWordWrap === "boolean" ? settings.tableDdlWordWrap : DEFAULT_EDITOR_SETTINGS.tableDdlWordWrap,
|
||||
vimModeEnabled: typeof settings.vimModeEnabled === "boolean" ? settings.vimModeEnabled : DEFAULT_EDITOR_SETTINGS.vimModeEnabled,
|
||||
@@ -1720,6 +1723,7 @@ export const useSettingsStore = defineStore("settings", () => {
|
||||
if (partial.autoAliasTables !== undefined) editorSettings.value.autoAliasTables = partial.autoAliasTables;
|
||||
if (partial.insertSpaceAfterCompletion !== undefined) editorSettings.value.insertSpaceAfterCompletion = partial.insertSpaceAfterCompletion === true;
|
||||
if (partial.sortCompletionColumnsAlphabetically !== undefined) editorSettings.value.sortCompletionColumnsAlphabetically = partial.sortCompletionColumnsAlphabetically === true;
|
||||
if (partial.selectFirstCompletionOnOpen !== undefined) editorSettings.value.selectFirstCompletionOnOpen = partial.selectFirstCompletionOnOpen === true;
|
||||
if (partial.wordWrap !== undefined) editorSettings.value.wordWrap = partial.wordWrap;
|
||||
if (partial.tableDdlWordWrap !== undefined) editorSettings.value.tableDdlWordWrap = partial.tableDdlWordWrap === true;
|
||||
if (partial.vimModeEnabled !== undefined) editorSettings.value.vimModeEnabled = partial.vimModeEnabled === true;
|
||||
|
||||
Reference in new issue
Block a user