feat(editor): dialect-aware SQL quick actions with per-database templates

This commit is contained in:
Mo-Moore authored and GitHub committed 2026-09-17 17:29:01 +08:00
1 parent 455117b8c5
commit 65b820e6a9
30 files changed
+1541 -150

No files matched your search

@@ -208,7 +208,23 @@ import { combineDataTypeForDatabase, dataTypeLengthInputValue, getDataTypeOption
import { useToast } from "@/composables/useToast";
import type { DatabaseType, SqlShortcutAction, SqlSnippet } from "@/types/database";
import { uuid } from "@/lib/common/utils";
import { findSqlShortcutConflicts, hasSqlShortcutConflicts as sqlShortcutsHaveConflicts, SQL_SHORTCUT_TABLE_TOKEN } from "@/lib/sql/sqlShortcutActions";
import { DEFAULT_SQL_SHORTCUT_SELECT_LIMIT } from "@/lib/sql/sqlDialectSelectLimit";
import {
BUILTIN_SQL_SHORTCUT_COUNT_ID,
BUILTIN_SQL_SHORTCUT_SELECT_LIMIT_ID,
canonicalSqlShortcutSql,
DEFAULT_SQL_SHORTCUTS,
findSqlShortcutConflicts,
hasSqlShortcutConflicts as sqlShortcutsHaveConflicts,
isBuiltinSqlShortcut,
mergeDefaultSqlShortcuts,
normalizeSqlShortcutLimit,
resolveSqlShortcutBody,
SQL_SHORTCUT_TABLE_TOKEN,
sqlShortcutDisplaySql,
deriveSqlShortcutDatabaseTypes,
} from "@/lib/sql/sqlShortcutActions";
import { applySqlShortcutBodyToAllSelected, buildSqlShortcutBodiesForSave, clearSqlShortcutFormDatabaseTypes as clearSqlShortcutFormBodies, switchSqlShortcutEditingDatabaseType, toggleSqlShortcutFormDatabaseType, type SqlShortcutFormBodies } from "@/lib/sql/sqlShortcutFormBodies";
import { DEFAULT_SQL_SNIPPETS } from "@/lib/sql/sqlCompletion";
import AiProviderLogo from "@/components/icons/AiProviderLogo.vue";
import AppLogo from "@/components/icons/AppLogo.vue";
@@ -738,10 +754,79 @@ function editableSnippet(snippet: SqlSnippet): SqlSnippet {
const editSnippets = ref<SqlSnippet[]>(settingsStore.editorSettings.snippets.map(editableSnippet));
function editableSqlShortcut(action: SqlShortcutAction): SqlShortcutAction {
return { ...action, enabled: action.enabled !== false };
const next: SqlShortcutAction = { ...action, enabled: action.enabled !== false };
if (action.kind === "select-limit" || action.id === BUILTIN_SQL_SHORTCUT_SELECT_LIMIT_ID) {
next.kind = "select-limit";
next.limit = normalizeSqlShortcutLimit(action.limit);
next.sql = canonicalSqlShortcutSql(next);
delete next.databaseTypes;
delete next.sqlByDatabaseType;
} else {
delete next.kind;
delete next.limit;
if (action.sqlByDatabaseType && Object.keys(action.sqlByDatabaseType).length > 0) {
next.sqlByDatabaseType = { ...action.sqlByDatabaseType };
} else {
delete next.sqlByDatabaseType;
}
const databaseTypes = deriveSqlShortcutDatabaseTypes(action.databaseTypes?.length ? [...action.databaseTypes] : undefined, next.sqlByDatabaseType);
if (databaseTypes) next.databaseTypes = databaseTypes;
else delete next.databaseTypes;
}
return next;
}
const editSqlShortcuts = ref<SqlShortcutAction[]>(settingsStore.editorSettings.sqlShortcuts.map(editableSqlShortcut));
const editSqlShortcuts = ref<SqlShortcutAction[]>(mergeDefaultSqlShortcuts(settingsStore.editorSettings.sqlShortcuts.map(editableSqlShortcut)));
type SqlShortcutFormState = {
label: string;
shortcut: string;
/** Default fallback template (payload.sql). */
sql: string;
/** Textarea content for the current editing context. */
body: string;
kind: "template" | "select-limit";
limit: number;
databaseTypes: DatabaseType[];
sqlByDatabaseType: Partial<Record<DatabaseType, string>>;
editingDatabaseType: DatabaseType | "";
};
function emptySqlShortcutForm(): SqlShortcutFormState {
const sql = `SELECT * FROM ${SQL_SHORTCUT_TABLE_TOKEN}`;
return {
label: "",
shortcut: "",
sql,
body: sql,
kind: "template",
limit: DEFAULT_SQL_SHORTCUT_SELECT_LIMIT,
databaseTypes: [],
sqlByDatabaseType: {},
editingDatabaseType: "",
};
}
function sqlShortcutFormBodies(form: SqlShortcutFormState): SqlShortcutFormBodies {
return {
sql: form.sql,
body: form.body,
databaseTypes: form.databaseTypes,
sqlByDatabaseType: form.sqlByDatabaseType,
editingDatabaseType: form.editingDatabaseType,
};
}
function patchSqlShortcutFormBodies(next: SqlShortcutFormBodies) {
sqlShortcutForm.value = {
...sqlShortcutForm.value,
sql: next.sql,
body: next.body,
databaseTypes: next.databaseTypes,
sqlByDatabaseType: next.sqlByDatabaseType,
editingDatabaseType: next.editingDatabaseType,
};
}
function currentEditorSettingsDraft(): EditorSettingsDraft {
return {
@@ -963,8 +1048,10 @@ const snippetFormPrefixError = ref("");
const sqlShortcutDialogOpen = ref(false);
const sqlShortcutEditingId = ref<string | null>(null);
const sqlShortcutForm = ref({ label: "", shortcut: "", sql: "" });
const sqlShortcutForm = ref<SqlShortcutFormState>(emptySqlShortcutForm());
const sqlShortcutFormLabelError = ref("");
const sqlShortcutPreviewDatabaseType = ref<DatabaseType>("mysql");
const sqlShortcutDatabaseTypesOpen = ref(false);
const editingSqlShortcutInputId = ref<string | null>(null);
const iconThemeBlackDescriptionText = computed(() => (isMacOS() ? t("settings.iconThemeBlackDescriptionMac") : t("settings.iconThemeBlackDescription")));
const layoutDescTruncated = {
@@ -1095,56 +1182,163 @@ function confirmDeleteSnippet(snippet: SqlSnippet) {
function openAddSqlShortcutDialog() {
sqlShortcutEditingId.value = null;
sqlShortcutForm.value = { label: "", shortcut: "", sql: `SELECT * FROM ${SQL_SHORTCUT_TABLE_TOKEN}` };
sqlShortcutForm.value = emptySqlShortcutForm();
sqlShortcutFormLabelError.value = "";
sqlShortcutPreviewDatabaseType.value = "mysql";
sqlShortcutDatabaseTypesOpen.value = false;
editingSqlShortcutInputId.value = null;
sqlShortcutDialogOpen.value = true;
}
function openEditSqlShortcutDialog(action: SqlShortcutAction) {
sqlShortcutEditingId.value = action.id;
const databaseTypes = action.databaseTypes ? [...action.databaseTypes] : [];
const sqlByDatabaseType = action.sqlByDatabaseType ? { ...action.sqlByDatabaseType } : {};
const editingDatabaseType = databaseTypes[0] ?? "";
const fallbackSql = action.sql;
sqlShortcutForm.value = {
label: action.label,
shortcut: action.shortcut,
sql: action.sql,
sql: fallbackSql,
body: editingDatabaseType ? resolveSqlShortcutBody(action, editingDatabaseType) : fallbackSql,
kind: action.kind === "select-limit" || action.id === BUILTIN_SQL_SHORTCUT_SELECT_LIMIT_ID ? "select-limit" : "template",
limit: normalizeSqlShortcutLimit(action.limit),
databaseTypes,
sqlByDatabaseType,
editingDatabaseType,
};
sqlShortcutFormLabelError.value = "";
sqlShortcutPreviewDatabaseType.value = databaseTypes[0] ?? "mysql";
sqlShortcutDatabaseTypesOpen.value = false;
editingSqlShortcutInputId.value = null;
sqlShortcutDialogOpen.value = true;
}
const sqlShortcutFormIsBuiltin = computed(() => !!sqlShortcutEditingId.value && isBuiltinSqlShortcut(sqlShortcutEditingId.value));
const sqlShortcutFormBuiltinLabel = computed(() => {
if (!sqlShortcutEditingId.value) return "";
return sqlShortcutDisplayLabel({
id: sqlShortcutEditingId.value,
label: sqlShortcutForm.value.label,
shortcut: "",
sql: "",
});
});
function sqlShortcutDisplayLabel(action: SqlShortcutAction): string {
if (action.id === BUILTIN_SQL_SHORTCUT_SELECT_LIMIT_ID) return t("settings.sqlShortcutBuiltinSelectLimit");
if (action.id === BUILTIN_SQL_SHORTCUT_COUNT_ID) return t("settings.sqlShortcutBuiltinCount");
return action.label;
}
function onSqlShortcutEditingDatabaseTypeChange(next: DatabaseType | "") {
if (!next) return;
patchSqlShortcutFormBodies(switchSqlShortcutEditingDatabaseType(sqlShortcutFormBodies(sqlShortcutForm.value), next));
}
function toggleSqlShortcutDatabaseType(dbType: DatabaseType) {
patchSqlShortcutFormBodies(toggleSqlShortcutFormDatabaseType(sqlShortcutFormBodies(sqlShortcutForm.value), dbType));
}
function clearSqlShortcutDatabaseTypes() {
patchSqlShortcutFormBodies(clearSqlShortcutFormBodies(sqlShortcutFormBodies(sqlShortcutForm.value)));
}
function applySqlShortcutSqlToAllSelected() {
patchSqlShortcutFormBodies(applySqlShortcutBodyToAllSelected(sqlShortcutFormBodies(sqlShortcutForm.value)));
toast(t("settings.sqlShortcutsApplySqlToAllSelectedDone"), 2500);
}
const sqlShortcutFormPreviewSql = computed(() =>
sqlShortcutDisplaySql(
{
id: sqlShortcutEditingId.value ?? "preview",
label: "preview",
shortcut: "",
sql: sqlShortcutForm.value.sql,
kind: sqlShortcutForm.value.kind,
limit: sqlShortcutForm.value.limit,
sqlByDatabaseType: sqlShortcutForm.value.sqlByDatabaseType,
},
sqlShortcutForm.value.kind === "select-limit" ? sqlShortcutPreviewDatabaseType.value : sqlShortcutForm.value.editingDatabaseType || undefined,
),
);
function sqlShortcutListSql(action: SqlShortcutAction): string {
return sqlShortcutDisplaySql(action);
}
function sqlShortcutDatabaseTypesLabel(action: SqlShortcutAction): string {
if (isBuiltinSqlShortcut(action.id) || !action.databaseTypes?.length) return t("settings.sqlShortcutsDatabaseTypesAll");
return action.databaseTypes.map((dbType) => dbTypeLabel(dbType)).join(", ");
}
function saveSqlShortcut() {
const editingId = sqlShortcutEditingId.value;
const existing = editingId ? editSqlShortcuts.value.find((item) => item.id === editingId) : undefined;
const isBuiltin = !!editingId && isBuiltinSqlShortcut(editingId);
if (isBuiltin && existing) {
const limit = normalizeSqlShortcutLimit(sqlShortcutForm.value.limit);
const payload: SqlShortcutAction = {
...existing,
shortcut: sqlShortcutForm.value.shortcut.trim(),
enabled: existing.enabled !== false,
};
if (existing.id === BUILTIN_SQL_SHORTCUT_SELECT_LIMIT_ID || existing.kind === "select-limit") {
payload.kind = "select-limit";
payload.limit = limit;
payload.sql = canonicalSqlShortcutSql({ kind: "select-limit", limit, sql: "" });
delete payload.databaseTypes;
delete payload.sqlByDatabaseType;
}
const nextShortcuts = editSqlShortcuts.value.map((item) => (item.id === editingId ? payload : item));
if (sqlShortcutsHaveConflicts(nextShortcuts, editShortcuts.value)) {
toast(t("settings.shortcutConflict"), 3000);
return;
}
editSqlShortcuts.value = nextShortcuts.map(editableSqlShortcut);
sqlShortcutDialogOpen.value = false;
void commitSqlShortcuts();
return;
}
const label = sqlShortcutForm.value.label.trim();
if (!label) {
sqlShortcutFormLabelError.value = t("settings.sqlShortcutsLabelRequired");
return;
}
const bodies = buildSqlShortcutBodiesForSave(sqlShortcutFormBodies(sqlShortcutForm.value));
const payload: SqlShortcutAction = {
id: sqlShortcutEditingId.value ?? uuid(),
id: editingId ?? uuid(),
label,
shortcut: sqlShortcutForm.value.shortcut.trim(),
sql: sqlShortcutForm.value.sql,
enabled: true,
sql: bodies.sql,
enabled: existing?.enabled !== false,
};
const nextShortcuts = sqlShortcutEditingId.value
? editSqlShortcuts.value.map((item) =>
item.id === sqlShortcutEditingId.value
? {
...payload,
enabled: item.enabled !== false,
}
: item,
)
: [...editSqlShortcuts.value, payload];
if (bodies.databaseTypes) payload.databaseTypes = bodies.databaseTypes;
if (bodies.sqlByDatabaseType) payload.sqlByDatabaseType = bodies.sqlByDatabaseType;
const nextShortcuts = editingId ? editSqlShortcuts.value.map((item) => (item.id === editingId ? payload : item)) : [...editSqlShortcuts.value, payload];
if (sqlShortcutsHaveConflicts(nextShortcuts, editShortcuts.value)) {
toast(t("settings.shortcutConflict"), 3000);
return;
}
editSqlShortcuts.value = nextShortcuts;
editSqlShortcuts.value = nextShortcuts.map(editableSqlShortcut);
sqlShortcutDialogOpen.value = false;
void commitSqlShortcuts();
}
function confirmDeleteSqlShortcut(action: SqlShortcutAction) {
if (isBuiltinSqlShortcut(action.id)) {
toast(t("settings.sqlShortcutsBuiltinDeleteBlocked"), 3000);
return;
}
if (window.confirm(t("settings.sqlShortcutsDeleteConfirm", { name: sqlShortcutDisplayLabel(action) }))) {
deleteSqlShortcut(action.id);
}
}
function setSqlShortcutEnabled(id: string, enabled: boolean) {
const idx = editSqlShortcuts.value.findIndex((item) => item.id === id);
if (idx === -1) return;
@@ -1160,16 +1354,11 @@ function setSqlShortcutEnabled(id: string, enabled: boolean) {
}
function deleteSqlShortcut(id: string) {
if (isBuiltinSqlShortcut(id)) return;
editSqlShortcuts.value = editSqlShortcuts.value.filter((item) => item.id !== id);
void commitSqlShortcuts();
}
function confirmDeleteSqlShortcut(action: SqlShortcutAction) {
if (window.confirm(t("settings.sqlShortcutsDeleteConfirm", { name: action.label }))) {
deleteSqlShortcut(action.id);
}
}
function onSqlShortcutBindingKeydown(id: string, event: KeyboardEvent) {
event.preventDefault();
event.stopPropagation();
@@ -1376,7 +1565,7 @@ function syncEditorSettingsDraftFromStore() {
editUpdateDownloadSource.value = settingsStore.editorSettings.updateDownloadSource;
editToolbarItems.value = { ...settingsStore.editorSettings.toolbarItems };
editSnippets.value = settingsStore.editorSettings.snippets.map(editableSnippet);
editSqlShortcuts.value = settingsStore.editorSettings.sqlShortcuts.map(editableSqlShortcut);
editSqlShortcuts.value = mergeDefaultSqlShortcuts(settingsStore.editorSettings.sqlShortcuts.map(editableSqlShortcut));
editSqlVariableSubstitutionEnabled.value = settingsStore.editorSettings.sqlVariableSubstitutionEnabled;
editSqlVariableSyntaxOverrides.value = normalizeSqlVariableSyntaxOverrides(settingsStore.editorSettings.sqlVariableSyntaxOverrides);
editClickTableNavigationTarget.value = settingsStore.editorSettings.clickTableNavigationTarget;
@@ -1514,7 +1703,7 @@ function applyEditorSettingsKeysToRefs(draft: EditorSettingsDraft, keys: readonl
redisKeyTemplates: (value) => normalizeRedisKeyTemplates(value as string[]).join("\n"),
toolbarItems: (value) => ({ ...(value as EditorSettings["toolbarItems"]) }),
snippets: (value) => (value as SqlSnippet[]).map(editableSnippet),
sqlShortcuts: (value) => (value as SqlShortcutAction[]).map(editableSqlShortcut),
sqlShortcuts: (value) => mergeDefaultSqlShortcuts((value as SqlShortcutAction[]).map(editableSqlShortcut)),
sqlVariableSyntaxOverrides: (value) => normalizeSqlVariableSyntaxOverrides(value as EditorSettings["sqlVariableSyntaxOverrides"]),
backgroundImage: (value) => cloneBackgroundImageDraft(value as BackgroundImageSettings),
});
@@ -2071,7 +2260,7 @@ function resetAllDefaults() {
editUpdateDownloadSource.value = DEFAULT_EDITOR_SETTINGS.updateDownloadSource;
editToolbarItems.value = { ...DEFAULT_EDITOR_SETTINGS.toolbarItems };
editSnippets.value = DEFAULT_SQL_SNIPPETS.map((s) => ({ ...s }));
editSqlShortcuts.value = [];
editSqlShortcuts.value = DEFAULT_SQL_SHORTCUTS.map(editableSqlShortcut);
}
function addTableColumnTemplateRow() {
@@ -7857,11 +8046,13 @@ onUnmounted(() => {
{{ t("settings.sqlShortcutsEmpty") }}
</div>
<div v-else class="overflow-x-auto rounded-md border">
<table class="w-full min-w-[720px] text-sm">
<table class="w-full min-w-[900px] text-sm">
<thead>
<tr class="border-b bg-muted/50">
<th class="px-3 py-2 text-left font-medium whitespace-nowrap">{{ t("settings.sqlShortcutsLabel") }}</th>
<th class="px-3 py-2 text-left font-medium whitespace-nowrap">{{ t("settings.sqlShortcutsSource") }}</th>
<th class="px-3 py-2 text-left font-medium whitespace-nowrap">{{ t("settings.shortcutPressShortcut") }}</th>
<th class="px-3 py-2 text-left font-medium whitespace-nowrap">{{ t("settings.sqlShortcutsDatabaseTypes") }}</th>
<th class="px-3 py-2 text-left font-medium whitespace-nowrap">{{ t("settings.snippetsStatus") }}</th>
<th class="px-3 py-2 text-left font-medium whitespace-nowrap">{{ t("settings.sqlShortcutsSql") }}</th>
<th class="px-3 py-2 w-20"></th>
@@ -7869,12 +8060,20 @@ onUnmounted(() => {
</thead>
<tbody>
<tr v-for="action in editSqlShortcuts" :key="action.id" class="border-b last:border-b-0 hover:bg-muted/30" :class="action.enabled === false ? 'text-muted-foreground' : ''">
<td class="px-3 py-2">{{ action.label }}</td>
<td class="px-3 py-2">{{ sqlShortcutDisplayLabel(action) }}</td>
<td class="px-3 py-2">
<Badge variant="outline" class="h-5 rounded-md px-1.5 text-[11px]" :class="isBuiltinSqlShortcut(action.id) ? 'border-primary/40 text-primary' : 'text-muted-foreground'">
{{ isBuiltinSqlShortcut(action.id) ? t("settings.sqlShortcutsSourceBuiltin") : t("settings.sqlShortcutsSourceCustom") }}
</Badge>
</td>
<td class="px-3 py-2">
<Badge variant="outline" class="h-5 rounded-md px-1.5 font-mono text-[11px] text-muted-foreground">
{{ action.shortcut ? formatShortcutPill(action.shortcut) : t("settings.sqlShortcutsUnbound") }}
</Badge>
</td>
<td class="max-w-[180px] truncate px-3 py-2 text-xs text-muted-foreground" :title="sqlShortcutDatabaseTypesLabel(action)">
{{ sqlShortcutDatabaseTypesLabel(action) }}
</td>
<td class="px-3 py-2">
<div class="flex items-center gap-2">
<Switch :id="`sql-shortcut-enabled-${action.id}`" :model-value="action.enabled !== false" size="sm" :aria-label="t('settings.sqlShortcutsToggle')" @update:model-value="(value: boolean) => setSqlShortcutEnabled(action.id, value)" />
@@ -7883,13 +8082,13 @@ onUnmounted(() => {
</Label>
</div>
</td>
<td class="max-w-[300px] truncate px-3 py-2 font-mono text-xs text-muted-foreground">{{ action.sql }}</td>
<td class="max-w-[300px] truncate px-3 py-2 font-mono text-xs text-muted-foreground" :title="sqlShortcutListSql(action)">{{ sqlShortcutListSql(action) }}</td>
<td class="px-3 py-2">
<div class="flex items-center gap-1">
<Button variant="ghost" size="icon-xs" @click="openEditSqlShortcutDialog(action)">
<Pencil class="size-3.5" />
</Button>
<Button variant="ghost" size="icon-xs" @click="confirmDeleteSqlShortcut(action)">
<Button v-if="!isBuiltinSqlShortcut(action.id)" variant="ghost" size="icon-xs" @click="confirmDeleteSqlShortcut(action)">
<Trash2 class="size-3.5" />
</Button>
</div>
@@ -9970,7 +10169,7 @@ LIMIT 100;</pre
<!-- SQL Shortcut Add/Edit Dialog -->
<Dialog :open="sqlShortcutDialogOpen" @update:open="sqlShortcutDialogOpen = $event">
<DialogContent class="sm:max-w-[560px]">
<DialogContent class="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>
{{ sqlShortcutEditingId ? t("settings.sqlShortcutsEditTitle") : t("settings.sqlShortcutsAddTitle") }}
@@ -9979,7 +10178,8 @@ LIMIT 100;</pre
<div class="flex flex-col gap-4 py-2">
<div class="flex flex-col gap-1.5">
<Label for="sql-shortcut-label">{{ t("settings.sqlShortcutsLabel") }}</Label>
<Input id="sql-shortcut-label" v-model="sqlShortcutForm.label" :placeholder="t('settings.sqlShortcutsLabelPlaceholder')" />
<Input v-if="!sqlShortcutFormIsBuiltin" id="sql-shortcut-label" v-model="sqlShortcutForm.label" :placeholder="t('settings.sqlShortcutsLabelPlaceholder')" />
<Input v-else id="sql-shortcut-label" :model-value="sqlShortcutFormBuiltinLabel" readonly class="bg-muted" />
<p v-if="sqlShortcutFormLabelError" class="text-xs text-destructive">
{{ sqlShortcutFormLabelError }}
</p>
@@ -10009,19 +10209,104 @@ LIMIT 100;</pre
</Button>
</div>
</div>
<div class="flex flex-col gap-1.5">
<Label for="sql-shortcut-sql">{{ t("settings.sqlShortcutsSql") }}</Label>
<textarea
id="sql-shortcut-sql"
v-model="sqlShortcutForm.sql"
:placeholder="t('settings.sqlShortcutsSqlPlaceholder')"
rows="6"
class="flex min-h-[120px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm font-mono shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
<!-- Built-in select-limit: row count + dialect preview -->
<template v-if="sqlShortcutFormIsBuiltin && sqlShortcutForm.kind === 'select-limit'">
<div class="flex flex-col gap-1.5">
<Label for="sql-shortcut-limit">{{ t("settings.sqlShortcutsLimit") }}</Label>
<Input id="sql-shortcut-limit" type="number" min="1" max="100000" v-model.number="sqlShortcutForm.limit" class="w-32" />
</div>
<div class="flex flex-col gap-1.5">
<div class="flex items-center justify-between gap-2">
<Label>{{ t("settings.sqlShortcutsPreview") }}</Label>
<Select v-model="sqlShortcutPreviewDatabaseType">
<SelectTrigger class="h-8 w-[160px]">
<SelectValue :placeholder="t('settings.sqlShortcutsPreviewDatabase')" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="dbType in dbTypeOptions" :key="dbType" :value="dbType">
{{ dbTypeLabel(dbType) }}
</SelectItem>
</SelectContent>
</Select>
</div>
<pre class="overflow-x-auto whitespace-pre-wrap rounded-md border bg-muted/30 px-3 py-2 font-mono text-xs leading-relaxed">{{ sqlShortcutFormPreviewSql }}</pre>
<p class="text-xs text-muted-foreground">{{ t("settings.sqlShortcutsSelectLimitHint") }}</p>
</div>
</template>
<!-- Built-in count: read-only SQL -->
<div v-else-if="sqlShortcutFormIsBuiltin" class="flex flex-col gap-1.5">
<Label>{{ t("settings.sqlShortcutsSql") }}</Label>
<pre class="overflow-x-auto whitespace-pre-wrap rounded-md border bg-muted/30 px-3 py-2 font-mono text-xs leading-relaxed">{{ sqlShortcutForm.sql }}</pre>
<p class="text-xs text-muted-foreground">
{{ t("settings.sqlShortcutsVariableHint", { token: SQL_SHORTCUT_TABLE_TOKEN }) }}
</p>
</div>
<!-- Custom add/edit: databases + SQL (optional per-DB body) -->
<template v-else>
<div class="flex flex-col gap-1.5">
<Label>{{ t("settings.sqlShortcutsDatabaseTypes") }}</Label>
<div class="flex flex-wrap items-center gap-2">
<Popover :open="sqlShortcutDatabaseTypesOpen" @update:open="(open) => (sqlShortcutDatabaseTypesOpen = open)">
<PopoverTrigger as-child>
<Button type="button" variant="outline" size="sm" class="h-8">
{{ sqlShortcutForm.databaseTypes.length === 0 ? t("settings.sqlShortcutsDatabaseTypesAll") : t("settings.sqlShortcutsDatabaseTypesSelected", { count: sqlShortcutForm.databaseTypes.length }) }}
<ChevronDown class="ml-1 h-3.5 w-3.5 opacity-60" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" class="w-64 p-2">
<button type="button" class="mb-1 flex w-full items-center gap-2 rounded-sm px-1 py-1 text-xs hover:bg-muted" @click="clearSqlShortcutDatabaseTypes">
<div class="flex h-4 w-4 shrink-0 items-center justify-center rounded-sm border" :class="sqlShortcutForm.databaseTypes.length === 0 ? 'border-primary bg-primary text-primary-foreground' : ''">
<Check v-if="sqlShortcutForm.databaseTypes.length === 0" class="h-3 w-3" />
</div>
{{ t("settings.sqlShortcutsDatabaseTypesAll") }}
</button>
<div class="max-h-48 overflow-auto border-t border-border/60 pt-1">
<button v-for="dbType in dbTypeOptions" :key="dbType" type="button" class="flex w-full items-center gap-2 rounded-sm px-1 py-1 text-xs hover:bg-muted" @click="toggleSqlShortcutDatabaseType(dbType)">
<div class="flex h-4 w-4 shrink-0 items-center justify-center rounded-sm border" :class="sqlShortcutForm.databaseTypes.includes(dbType) ? 'border-primary bg-primary text-primary-foreground' : ''">
<Check v-if="sqlShortcutForm.databaseTypes.includes(dbType)" class="h-3 w-3" />
</div>
{{ dbTypeLabel(dbType) }}
</button>
</div>
</PopoverContent>
</Popover>
</div>
<p class="text-xs text-muted-foreground">{{ t("settings.sqlShortcutsDatabaseTypesHint") }}</p>
</div>
<div class="flex flex-col gap-1.5">
<div class="flex items-center justify-between gap-2">
<Label for="sql-shortcut-sql">{{ t("settings.sqlShortcutsSql") }}</Label>
<div v-if="sqlShortcutForm.databaseTypes.length > 0" class="flex items-center gap-2">
<Select :model-value="sqlShortcutForm.editingDatabaseType || sqlShortcutForm.databaseTypes[0]" @update:model-value="(value) => onSqlShortcutEditingDatabaseTypeChange((value as DatabaseType) || '')">
<SelectTrigger class="h-8 w-[160px]">
<SelectValue :placeholder="t('settings.sqlShortcutsSqlDatabase')" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="dbType in sqlShortcutForm.databaseTypes" :key="dbType" :value="dbType">
{{ dbTypeLabel(dbType) }}
</SelectItem>
</SelectContent>
</Select>
<Button type="button" variant="outline" size="sm" class="h-8 shrink-0" @click="applySqlShortcutSqlToAllSelected">
{{ t("settings.sqlShortcutsApplySqlToAllSelected") }}
</Button>
</div>
</div>
<textarea
id="sql-shortcut-sql"
v-model="sqlShortcutForm.body"
:placeholder="t('settings.sqlShortcutsSqlPlaceholder')"
rows="6"
class="flex min-h-[120px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm font-mono shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
<p class="text-xs text-muted-foreground">
{{ t("settings.sqlShortcutsVariableHint", { token: SQL_SHORTCUT_TABLE_TOKEN }) }}
</p>
</div>
</template>
</div>
<DialogFooter>
<Button variant="outline" @click="sqlShortcutDialogOpen = false">{{ t("settings.cancel") }}</Button>
@@ -163,7 +163,8 @@ import { appendSqlCompletionSpace } from "@/lib/editor/sqlCompletionInsertion";
import { batchColumnSelectionColumnList, batchColumnSelectionInsertReplacement, batchColumnSelectionReplaceTo, isBatchColumnSelectionCompletionActive, shouldResolveSqlColumnCompletion } from "@/lib/editor/batchColumnSelection";
import { compareSqlCompletions, completionLabelPresentation } from "@/lib/editor/sqlCompletionPresentation";
import { clampEditorFontSize, createEditorWheelZoomGestureGuard, createEditorZoomCommitScheduler, fontSizeFromGestureScale, fontSizeFromWheelDelta } from "@/lib/editor/editorZoom";
import { enabledSqlShortcutActions, resolveSqlShortcutTemplate } from "@/lib/sql/sqlShortcutActions";
import { buildSqlShortcutExecutionSql, enabledSqlShortcutActions, resolveSqlShortcutForDatabase, uniqueSqlShortcutBindings } from "@/lib/sql/sqlShortcutActions";
import { resolveSqlShortcutTableToken } from "@/lib/sql/sqlShortcutTableTarget";
import { normalizeShortcutSettings, shortcutToCodeMirrorKey } from "@/lib/editor/shortcutRegistry";
import { trimmedSelectionLayer } from "@/lib/editor/codemirrorTrimmedSelectionLayer";
import { currentStatementFrameLayer } from "@/lib/editor/codemirrorCurrentStatementFrameLayer";
@@ -2302,13 +2303,21 @@ function handleSqlIntentionActions(currentView: EditorViewType): boolean {
}
function runSqlShortcutAction(action: ReturnType<typeof enabledSqlShortcutActions>[number], currentView: EditorViewType, event?: KeyboardEvent): boolean {
// Non-SQL editors (Redis / Mongo / ES / …) keep their own command languages; do not inject SELECT templates.
if (queryEditorSelectionLanguage() !== "sql") return false;
if (shouldBlockExecutionShortcut(event, currentView)) return true;
if (props.readOnly) return true;
const { from, to, empty } = currentView.state.selection.main;
if (empty) return false;
const selected = currentView.state.sliceDoc(from, to).trim();
const { from, to, empty, head } = currentView.state.selection.main;
let selected: string | null = null;
if (!empty) {
selected = currentView.state.sliceDoc(from, to).trim() || null;
} else {
const line = currentView.state.doc.lineAt(head);
const localHead = Math.min(Math.max(0, head - line.from), line.text.length);
selected = resolveSqlShortcutTableToken(line.text, { from: localHead, to: localHead, empty: true, head: localHead });
}
if (!selected) return false;
const sql = resolveSqlShortcutTemplate(action.sql, selected);
const sql = buildSqlShortcutExecutionSql(action, selected, props.databaseType);
emitExecutionRequest(sql);
return true;
}
@@ -2345,11 +2354,27 @@ function runKeymapExtension(codeMirrorKeymap: (typeof import("@codemirror/view")
isReadOnly: () => !!props.readOnly,
});
const sqlShortcutActions = enabledSqlShortcutActions(settingsStore.editorSettings.sqlShortcuts);
const sqlShortcutKeymapActions = sqlShortcutActions.filter((action) => !isCharacterProducingShortcut(action.shortcut));
const sqlShortcutBindings = sqlShortcutKeymapActions.flatMap((action) => binding(action.shortcut, (currentView) => runSqlShortcutAction(action, currentView)));
const sqlShortcutKeymapBindings = uniqueSqlShortcutBindings(sqlShortcutActions).filter((shortcut) => !isCharacterProducingShortcut(shortcut));
// Do not set preventDefault: true — when run returns false (wrong DB scope / no table token),
// CodeMirror must not swallow the browser default. Returning true still prevents default.
const sqlShortcutBindings = sqlShortcutKeymapBindings.flatMap((shortcut) =>
shortcut
? [
{
key: shortcutToCodeMirrorKey(shortcut),
run: (currentView: EditorViewType) => {
const action = resolveSqlShortcutForDatabase(settingsStore.editorSettings.sqlShortcuts, shortcut, props.databaseType);
if (!action) return false;
return runSqlShortcutAction(action, currentView);
},
},
]
: [],
);
const sqlShortcutDomHandler = createQueryEditorSqlShortcutDomHandler(
() => settingsStore.editorSettings.sqlShortcuts,
(action, currentView, event) => runSqlShortcutAction(action, currentView, event),
() => props.databaseType,
);
const combinedDomKeydownHandler = (event: KeyboardEvent, view: EditorViewType) => {
if (replaceShortcutHandler(event)) return true;
+22
View File
@@ -7168,6 +7168,28 @@ export default withEnglishFallback({
sqlShortcutsUnbound: "Təyin edilməyib",
sqlShortcutsToggle: "SQL qısayolunu aktiv və ya deaktiv et",
sqlShortcutsDeleteConfirm: '"{name}" SQL qısayolu silinsin?',
sqlShortcutsSqlDatabase: "Edit for database",
sqlShortcutsApplySqlToAllSelected: "Apply to all selected",
sqlShortcutsApplySqlToAllSelectedDone: "Applied to all selected databases.",
sqlShortcutsBuiltinDeleteBlocked: "Built-in templates cannot be deleted. Disable them instead.",
sqlShortcutBuiltinSelectLimit: "Select first N rows",
sqlShortcutBuiltinCount: "Count rows",
sqlShortcutsSource: "Type",
sqlShortcutsSourceBuiltin: "Built-in",
sqlShortcutsSourceCustom: "Custom",
sqlShortcutsKind: "Action type",
sqlShortcutsKindTemplate: "SQL template",
sqlShortcutsKindTemplateHint: "Run your own SQL with ${table}.",
sqlShortcutsKindSelectLimit: "Select first N rows",
sqlShortcutsKindSelectLimitHint: "Auto-adapts LIMIT / TOP / ROWNUM for the current database.",
sqlShortcutsLimit: "Row limit",
sqlShortcutsDatabaseTypes: "Databases",
sqlShortcutsDatabaseTypesAll: "All databases",
sqlShortcutsDatabaseTypesSelected: "{count} selected",
sqlShortcutsDatabaseTypesHint: "Leave as all databases, or limit to specific types. Same shortcut may repeat across non-overlapping types.",
sqlShortcutsPreview: "SQL preview",
sqlShortcutsPreviewDatabase: "Preview dialect",
sqlShortcutsSelectLimitHint: "At run time, SQL is generated for the active connection dialect.",
syncWebDavTitle: "WebDAV sinxronlaşdırması",
syncWebDavDescription: "Yadda saxlanılmış SQL kitabxanası qeydləri daxil olmaqla DBX anlıq nüsxəsini WebDAV-uyğun saxlama xidmətinə yükləyin və ya oradan bərpa edin.",
syncWebDavWebDescription: "Veb rejimində DBX serveri brauzerin CORS məhdudiyyətləri olmadan WebDAV-a daxil olur. Yadda saxlanılmış parollar cari DBX instansiyasında qalır.",
+23 -1
View File
@@ -7352,7 +7352,7 @@ export default {
snippetsAddTitle: "Add Snippet",
snippetsEditTitle: "Edit Snippet",
sqlShortcutsTitle: "SQL Quick Actions",
sqlShortcutsDescription: "Custom SQL templates executed after selecting a table name in the SQL editor. Select a table identifier first; shortcuts with Cmd/Ctrl are recommended.",
sqlShortcutsDescription: "Built-in templates and custom SQL actions after selecting a table name in the SQL editor (not Redis/Mongo/ES). Defaults include first N rows and count.",
sqlShortcutsAdd: "Add",
sqlShortcutsAddTitle: "Add SQL Quick Action",
sqlShortcutsEditTitle: "Edit SQL Quick Action",
@@ -7361,11 +7361,33 @@ export default {
sqlShortcutsLabelRequired: "Label is required.",
sqlShortcutsSql: "SQL Template",
sqlShortcutsSqlPlaceholder: "SELECT COUNT(*) FROM ${table}",
sqlShortcutsSqlDatabase: "Edit for database",
sqlShortcutsApplySqlToAllSelected: "Apply to all selected",
sqlShortcutsApplySqlToAllSelectedDone: "Applied to all selected databases.",
sqlShortcutsVariableHint: "Use {token} for the selected table name.",
sqlShortcutsEmpty: "No SQL quick actions yet.",
sqlShortcutsUnbound: "Unbound",
sqlShortcutsToggle: "Enable or disable SQL shortcut",
sqlShortcutsDeleteConfirm: 'Delete SQL shortcut "{name}"?',
sqlShortcutsBuiltinDeleteBlocked: "Built-in templates cannot be deleted. Disable them instead.",
sqlShortcutBuiltinSelectLimit: "Select first N rows",
sqlShortcutBuiltinCount: "Count rows",
sqlShortcutsSource: "Type",
sqlShortcutsSourceBuiltin: "Built-in",
sqlShortcutsSourceCustom: "Custom",
sqlShortcutsKind: "Action type",
sqlShortcutsKindTemplate: "SQL template",
sqlShortcutsKindTemplateHint: "Run your own SQL with ${table}.",
sqlShortcutsKindSelectLimit: "Select first N rows",
sqlShortcutsKindSelectLimitHint: "Auto-adapts LIMIT / TOP / ROWNUM for the current database.",
sqlShortcutsLimit: "Row limit",
sqlShortcutsDatabaseTypes: "Databases",
sqlShortcutsDatabaseTypesAll: "All databases",
sqlShortcutsDatabaseTypesSelected: "{count} selected",
sqlShortcutsDatabaseTypesHint: "Leave as all databases, or limit to specific types. When scoped, switching databases keeps each body separately; use Apply to all selected to copy the current template. Same shortcut may repeat across non-overlapping types.",
sqlShortcutsPreview: "SQL preview",
sqlShortcutsPreviewDatabase: "Preview dialect",
sqlShortcutsSelectLimitHint: "At run time, SQL is generated for the active connection dialect.",
syncWebDavTitle: "WebDAV Sync",
syncWebDavDescription: "Upload or restore a DBX snapshot, including saved SQL library entries, from a WebDAV-compatible storage service.",
syncWebDavWebDescription: "In Web mode, the DBX server accesses WebDAV without browser CORS restrictions. Saved passwords remain in the current DBX instance.",
+22
View File
@@ -6920,6 +6920,28 @@ export default withEnglishFallback({
sqlShortcutsUnbound: "Sin asignar",
sqlShortcutsToggle: "Activar o desactivar atajo SQL",
sqlShortcutsDeleteConfirm: '¿Eliminar el atajo SQL "{name}"?',
sqlShortcutsSqlDatabase: "Editar para base de datos",
sqlShortcutsApplySqlToAllSelected: "Aplicar a todas las seleccionadas",
sqlShortcutsApplySqlToAllSelectedDone: "Aplicado a todas las bases de datos seleccionadas.",
sqlShortcutsBuiltinDeleteBlocked: "Las plantillas integradas no se pueden eliminar. Desactívelas en su lugar.",
sqlShortcutBuiltinSelectLimit: "Seleccionar las primeras N filas",
sqlShortcutBuiltinCount: "Contar filas",
sqlShortcutsSource: "Tipo",
sqlShortcutsSourceBuiltin: "Integrada",
sqlShortcutsSourceCustom: "Personalizada",
sqlShortcutsKind: "Action type",
sqlShortcutsKindTemplate: "SQL template",
sqlShortcutsKindTemplateHint: "Run your own SQL with ${table}.",
sqlShortcutsKindSelectLimit: "Select first N rows",
sqlShortcutsKindSelectLimitHint: "Auto-adapts LIMIT / TOP / ROWNUM for the current database.",
sqlShortcutsLimit: "Row limit",
sqlShortcutsDatabaseTypes: "Databases",
sqlShortcutsDatabaseTypesAll: "All databases",
sqlShortcutsDatabaseTypesSelected: "{count} selected",
sqlShortcutsDatabaseTypesHint: "Leave as all databases, or limit to specific types. Same shortcut may repeat across non-overlapping types.",
sqlShortcutsPreview: "SQL preview",
sqlShortcutsPreviewDatabase: "Preview dialect",
sqlShortcutsSelectLimitHint: "At run time, SQL is generated for the active connection dialect.",
syncWebDavTitle: "Sincronización WebDAV",
syncWebDavDescription: "Sube o restaura una instantánea de DBX, incluida la biblioteca SQL guardada, desde almacenamiento compatible con WebDAV.",
syncWebDavWebDescription: "En modo Web, el servidor DBX accede a WebDAV sin las restricciones CORS del navegador. Las contraseñas guardadas permanecen en la instancia DBX actual.",
+22
View File
@@ -6920,6 +6920,28 @@ export default withEnglishFallback({
sqlShortcutsUnbound: "Non assegnato",
sqlShortcutsToggle: "Attiva o disattiva scorciatoia SQL",
sqlShortcutsDeleteConfirm: 'Eliminare la scorciatoia SQL "{name}"?',
sqlShortcutsSqlDatabase: "Edit for database",
sqlShortcutsApplySqlToAllSelected: "Apply to all selected",
sqlShortcutsApplySqlToAllSelectedDone: "Applied to all selected databases.",
sqlShortcutsBuiltinDeleteBlocked: "Built-in templates cannot be deleted. Disable them instead.",
sqlShortcutBuiltinSelectLimit: "Select first N rows",
sqlShortcutBuiltinCount: "Count rows",
sqlShortcutsSource: "Type",
sqlShortcutsSourceBuiltin: "Built-in",
sqlShortcutsSourceCustom: "Custom",
sqlShortcutsKind: "Action type",
sqlShortcutsKindTemplate: "SQL template",
sqlShortcutsKindTemplateHint: "Run your own SQL with ${table}.",
sqlShortcutsKindSelectLimit: "Select first N rows",
sqlShortcutsKindSelectLimitHint: "Auto-adapts LIMIT / TOP / ROWNUM for the current database.",
sqlShortcutsLimit: "Row limit",
sqlShortcutsDatabaseTypes: "Databases",
sqlShortcutsDatabaseTypesAll: "All databases",
sqlShortcutsDatabaseTypesSelected: "{count} selected",
sqlShortcutsDatabaseTypesHint: "Leave as all databases, or limit to specific types. Same shortcut may repeat across non-overlapping types.",
sqlShortcutsPreview: "SQL preview",
sqlShortcutsPreviewDatabase: "Preview dialect",
sqlShortcutsSelectLimitHint: "At run time, SQL is generated for the active connection dialect.",
syncWebDavTitle: "Sincronizzazione WebDAV",
syncWebDavDescription: "Carica o ripristina uno snapshot di DBX, inclusa la libreria SQL salvata, da un servizio di archiviazione compatibile con WebDAV.",
syncWebDavWebDescription: "In modalità Web, il server DBX accede a WebDAV senza le limitazioni CORS del browser. Le password salvate restano nell'istanza DBX corrente.",
+22
View File
@@ -6944,6 +6944,28 @@ export default withEnglishFallback({
sqlShortcutsUnbound: "未割り当て",
sqlShortcutsToggle: "SQLショートカットを有効または無効にする",
sqlShortcutsDeleteConfirm: "SQLショートカット「{name}」を削除しますか?",
sqlShortcutsSqlDatabase: "Edit for database",
sqlShortcutsApplySqlToAllSelected: "Apply to all selected",
sqlShortcutsApplySqlToAllSelectedDone: "Applied to all selected databases.",
sqlShortcutsBuiltinDeleteBlocked: "Built-in templates cannot be deleted. Disable them instead.",
sqlShortcutBuiltinSelectLimit: "Select first N rows",
sqlShortcutBuiltinCount: "Count rows",
sqlShortcutsSource: "Type",
sqlShortcutsSourceBuiltin: "Built-in",
sqlShortcutsSourceCustom: "Custom",
sqlShortcutsKind: "Action type",
sqlShortcutsKindTemplate: "SQL template",
sqlShortcutsKindTemplateHint: "Run your own SQL with ${table}.",
sqlShortcutsKindSelectLimit: "Select first N rows",
sqlShortcutsKindSelectLimitHint: "Auto-adapts LIMIT / TOP / ROWNUM for the current database.",
sqlShortcutsLimit: "Row limit",
sqlShortcutsDatabaseTypes: "Databases",
sqlShortcutsDatabaseTypesAll: "All databases",
sqlShortcutsDatabaseTypesSelected: "{count} selected",
sqlShortcutsDatabaseTypesHint: "Leave as all databases, or limit to specific types. Same shortcut may repeat across non-overlapping types.",
sqlShortcutsPreview: "SQL preview",
sqlShortcutsPreviewDatabase: "Preview dialect",
sqlShortcutsSelectLimitHint: "At run time, SQL is generated for the active connection dialect.",
syncWebDavTitle: "WebDAV同期",
syncWebDavDescription: "保存済みSQLライブラリを含むDBXスナップショットをWebDAV互換ストレージへアップロードまたは復元します。",
syncWebDavWebDescription: "WebモードではDBXサーバーがWebDAVへ接続するため、ブラウザーのCORS制限を受けません。保存したパスワードは現在のDBXインスタンス内に保持されます。",
+22
View File
@@ -6674,6 +6674,28 @@ export default withEnglishFallback({
sqlShortcutsUnbound: "미할당",
sqlShortcutsToggle: "SQL 단축키 활성화 또는 비활성화",
sqlShortcutsDeleteConfirm: 'SQL 단축키 "{name}"을(를) 삭제하시겠습니까?',
sqlShortcutsSqlDatabase: "Edit for database",
sqlShortcutsApplySqlToAllSelected: "Apply to all selected",
sqlShortcutsApplySqlToAllSelectedDone: "Applied to all selected databases.",
sqlShortcutsBuiltinDeleteBlocked: "Built-in templates cannot be deleted. Disable them instead.",
sqlShortcutBuiltinSelectLimit: "Select first N rows",
sqlShortcutBuiltinCount: "Count rows",
sqlShortcutsSource: "Type",
sqlShortcutsSourceBuiltin: "Built-in",
sqlShortcutsSourceCustom: "Custom",
sqlShortcutsKind: "Action type",
sqlShortcutsKindTemplate: "SQL template",
sqlShortcutsKindTemplateHint: "Run your own SQL with ${table}.",
sqlShortcutsKindSelectLimit: "Select first N rows",
sqlShortcutsKindSelectLimitHint: "Auto-adapts LIMIT / TOP / ROWNUM for the current database.",
sqlShortcutsLimit: "Row limit",
sqlShortcutsDatabaseTypes: "Databases",
sqlShortcutsDatabaseTypesAll: "All databases",
sqlShortcutsDatabaseTypesSelected: "{count} selected",
sqlShortcutsDatabaseTypesHint: "Leave as all databases, or limit to specific types. Same shortcut may repeat across non-overlapping types.",
sqlShortcutsPreview: "SQL preview",
sqlShortcutsPreviewDatabase: "Preview dialect",
sqlShortcutsSelectLimitHint: "At run time, SQL is generated for the active connection dialect.",
syncWebDavTitle: "WebDAV 동기화",
syncWebDavDescription: "WebDAV 호환 저장 서비스에서 DBX 스냅샷을 업로드하거나 복원합니다(저장된 SQL 라이브러리 항목 포함).",
syncWebDavWebDescription: "Web 모드에서는 DBX 서버가 WebDAV에 연결하므로 브라우저 CORS 제한을 받지 않습니다. 저장된 비밀번호는 현재 DBX 인스턴스에 유지됩니다.",
+22
View File
@@ -6922,6 +6922,28 @@ export default withEnglishFallback({
sqlShortcutsUnbound: "Não atribuído",
sqlShortcutsToggle: "Ativar ou desativar atalho SQL",
sqlShortcutsDeleteConfirm: 'Excluir o atalho SQL "{name}"?',
sqlShortcutsSqlDatabase: "Edit for database",
sqlShortcutsApplySqlToAllSelected: "Apply to all selected",
sqlShortcutsApplySqlToAllSelectedDone: "Applied to all selected databases.",
sqlShortcutsBuiltinDeleteBlocked: "Built-in templates cannot be deleted. Disable them instead.",
sqlShortcutBuiltinSelectLimit: "Select first N rows",
sqlShortcutBuiltinCount: "Count rows",
sqlShortcutsSource: "Type",
sqlShortcutsSourceBuiltin: "Built-in",
sqlShortcutsSourceCustom: "Custom",
sqlShortcutsKind: "Action type",
sqlShortcutsKindTemplate: "SQL template",
sqlShortcutsKindTemplateHint: "Run your own SQL with ${table}.",
sqlShortcutsKindSelectLimit: "Select first N rows",
sqlShortcutsKindSelectLimitHint: "Auto-adapts LIMIT / TOP / ROWNUM for the current database.",
sqlShortcutsLimit: "Row limit",
sqlShortcutsDatabaseTypes: "Databases",
sqlShortcutsDatabaseTypesAll: "All databases",
sqlShortcutsDatabaseTypesSelected: "{count} selected",
sqlShortcutsDatabaseTypesHint: "Leave as all databases, or limit to specific types. Same shortcut may repeat across non-overlapping types.",
sqlShortcutsPreview: "SQL preview",
sqlShortcutsPreviewDatabase: "Preview dialect",
sqlShortcutsSelectLimitHint: "At run time, SQL is generated for the active connection dialect.",
syncWebDavTitle: "Sincronização WebDAV",
syncWebDavDescription: "Envie ou restaure um snapshot do DBX, incluindo a biblioteca SQL salva, a partir de um serviço de armazenamento compatível com WebDAV.",
syncWebDavWebDescription: "No modo Web, o servidor DBX acessa o WebDAV sem as restrições de CORS do navegador. As senhas salvas permanecem na instância DBX atual.",
+22
View File
@@ -7049,6 +7049,28 @@ export default withEnglishFallback({
sqlShortcutsUnbound: "Atanmamış",
sqlShortcutsToggle: "SQL kısayolunu etkinleştir veya devre dışı bırak",
sqlShortcutsDeleteConfirm: '"{name}" SQL kısayolu silinsin mi?',
sqlShortcutsSqlDatabase: "Edit for database",
sqlShortcutsApplySqlToAllSelected: "Apply to all selected",
sqlShortcutsApplySqlToAllSelectedDone: "Applied to all selected databases.",
sqlShortcutsBuiltinDeleteBlocked: "Built-in templates cannot be deleted. Disable them instead.",
sqlShortcutBuiltinSelectLimit: "Select first N rows",
sqlShortcutBuiltinCount: "Count rows",
sqlShortcutsSource: "Type",
sqlShortcutsSourceBuiltin: "Built-in",
sqlShortcutsSourceCustom: "Custom",
sqlShortcutsKind: "Action type",
sqlShortcutsKindTemplate: "SQL template",
sqlShortcutsKindTemplateHint: "Run your own SQL with ${table}.",
sqlShortcutsKindSelectLimit: "Select first N rows",
sqlShortcutsKindSelectLimitHint: "Auto-adapts LIMIT / TOP / ROWNUM for the current database.",
sqlShortcutsLimit: "Row limit",
sqlShortcutsDatabaseTypes: "Databases",
sqlShortcutsDatabaseTypesAll: "All databases",
sqlShortcutsDatabaseTypesSelected: "{count} selected",
sqlShortcutsDatabaseTypesHint: "Leave as all databases, or limit to specific types. Same shortcut may repeat across non-overlapping types.",
sqlShortcutsPreview: "SQL preview",
sqlShortcutsPreviewDatabase: "Preview dialect",
sqlShortcutsSelectLimitHint: "At run time, SQL is generated for the active connection dialect.",
syncWebDavTitle: "WebDAV Eşitleme",
syncWebDavDescription: "Kayıtlı SQL kitaplığı girdileri dâhil bir DBX anlık görüntüsünü WebDAV uyumlu bir depolama servisine yükleyin ya da oradan geri yükleyin.",
syncWebDavWebDescription: "Web modunda DBX sunucusu WebDAV'a tarayıcı CORS kısıtları olmadan erişir. Kayıtlı parolalar geçerli DBX örneğinde kalır.",
+25 -3
View File
@@ -7325,20 +7325,42 @@ export default withEnglishFallback({
snippetsAddTitle: "添加片段",
snippetsEditTitle: "编辑片段",
sqlShortcutsTitle: "SQL 快捷操作",
sqlShortcutsDescription: "自定义在 SQL 编辑器中选中表名后执行的 SQL 模板。需先选中表名;建议使用含 Cmd/Ctrl 的组合键。",
sqlShortcutsDescription: "在 SQL 编辑器中选中表名后执行内置模板或自定义 SQL(不作用于 Redis/Mongo/ES)。默认含查前 N 条与统计行数;适用库不重叠时可共用快捷键。",
sqlShortcutsAdd: "添加",
sqlShortcutsAddTitle: "添加 SQL 快捷键",
sqlShortcutsEditTitle: "编辑 SQL 快捷键",
sqlShortcutsAddTitle: "添加 SQL 快捷操作",
sqlShortcutsEditTitle: "编辑 SQL 快捷操作",
sqlShortcutsLabel: "显示名",
sqlShortcutsLabelPlaceholder: "如:统计行数",
sqlShortcutsLabelRequired: "显示名为必填项。",
sqlShortcutsSql: "SQL 模板",
sqlShortcutsSqlPlaceholder: "SELECT COUNT(*) FROM ${table}",
sqlShortcutsSqlDatabase: "编辑适用库",
sqlShortcutsApplySqlToAllSelected: "应用到全部选中库",
sqlShortcutsApplySqlToAllSelectedDone: "已应用到全部选中库。",
sqlShortcutsVariableHint: "使用 {token} 表示选中的表名。",
sqlShortcutsEmpty: "暂无 SQL 快捷操作。",
sqlShortcutsUnbound: "未绑定",
sqlShortcutsToggle: "启用或关闭 SQL 快捷键",
sqlShortcutsDeleteConfirm: "删除 SQL 快捷键「{name}」?",
sqlShortcutsBuiltinDeleteBlocked: "内置模板不可删除,可关闭启用开关。",
sqlShortcutBuiltinSelectLimit: "查前 N 条",
sqlShortcutBuiltinCount: "统计行数",
sqlShortcutsSource: "类型",
sqlShortcutsSourceBuiltin: "内置模板",
sqlShortcutsSourceCustom: "自定义",
sqlShortcutsKind: "操作类型",
sqlShortcutsKindTemplate: "SQL 模板",
sqlShortcutsKindTemplateHint: "使用 ${table} 编写自定义 SQL。",
sqlShortcutsKindSelectLimit: "查前 N 条",
sqlShortcutsKindSelectLimitHint: "按当前连接自动生成 LIMIT / TOP / ROWNUM 等语法。",
sqlShortcutsLimit: "行数",
sqlShortcutsDatabaseTypes: "适用数据库",
sqlShortcutsDatabaseTypesAll: "全部数据库",
sqlShortcutsDatabaseTypesSelected: "已选 {count} 种",
sqlShortcutsDatabaseTypesHint: "默认适用于全部数据库;也可限定类型。限定后切换库会分别暂存正文;可用「应用到全部选中库」复制当前模板。同库快捷键不可冲突,跨库可重复。",
sqlShortcutsPreview: "SQL 预览",
sqlShortcutsPreviewDatabase: "预览方言",
sqlShortcutsSelectLimitHint: "执行时会按当前连接的数据库类型生成 SQL。",
syncWebDavTitle: "WebDAV 同步",
syncWebDavDescription: "将包含 SQL 库的 DBX 快照上传到兼容 WebDAV 的存储服务,或从远端恢复。",
syncWebDavWebDescription: "Web 模式下由 DBX 服务端访问 WebDAV,不受浏览器 CORS 限制;保存的密码会留在当前 DBX 实例中。",
+22
View File
@@ -6234,6 +6234,28 @@ export default withEnglishFallback({
sqlShortcutsUnbound: "未綁定",
sqlShortcutsToggle: "啟用或關閉 SQL 快捷鍵",
sqlShortcutsDeleteConfirm: "刪除 SQL 快捷鍵「{name}」?",
sqlShortcutsSqlDatabase: "編輯適用庫",
sqlShortcutsApplySqlToAllSelected: "套用到全部選中庫",
sqlShortcutsApplySqlToAllSelectedDone: "已套用到全部選中庫。",
sqlShortcutsBuiltinDeleteBlocked: "內建範本不可刪除,可關閉啟用開關。",
sqlShortcutBuiltinSelectLimit: "查前 N 條",
sqlShortcutBuiltinCount: "統計列數",
sqlShortcutsSource: "類型",
sqlShortcutsSourceBuiltin: "內建範本",
sqlShortcutsSourceCustom: "自訂",
sqlShortcutsKind: "操作類型",
sqlShortcutsKindTemplate: "SQL 範本",
sqlShortcutsKindTemplateHint: "使用 ${table} 編寫自訂 SQL。",
sqlShortcutsKindSelectLimit: "查前 N 筆",
sqlShortcutsKindSelectLimitHint: "依目前連線自動產生 LIMIT / TOP / ROWNUM 等語法。",
sqlShortcutsLimit: "筆數",
sqlShortcutsDatabaseTypes: "適用資料庫",
sqlShortcutsDatabaseTypesAll: "全部資料庫",
sqlShortcutsDatabaseTypesSelected: "已選 {count} 種",
sqlShortcutsDatabaseTypesHint: "預設適用全部資料庫;也可限定類型。同庫快捷鍵不可衝突,跨庫可重複。",
sqlShortcutsPreview: "SQL 預覽",
sqlShortcutsPreviewDatabase: "預覽方言",
sqlShortcutsSelectLimitHint: "執行時會依目前連線的資料庫類型產生 SQL。",
syncWebDavTitle: "WebDAV 同步",
syncWebDavDescription: "上傳包含 SQL 庫的 DBX 快照到相容 WebDAV 的儲存服務,或從該服務還原快照。",
syncWebDavWebDescription: "Web 模式由 DBX 伺服器端存取 WebDAV,不受瀏覽器 CORS 限制;儲存的密碼會保留在目前 DBX 執行個體中。",
@@ -132,10 +132,14 @@ describe("QueryEditor execution routing", () => {
it("routes custom SQL shortcuts through selection-aware execution with dual keymap and DOM handlers", () => {
expect(queryEditorSource).toContain("function runSqlShortcutAction(");
expect(queryEditorSource).toContain("resolveSqlShortcutTemplate(action.sql, selected)");
expect(queryEditorSource).toContain('if (queryEditorSelectionLanguage() !== "sql") return false;');
expect(queryEditorSource).toContain("buildSqlShortcutExecutionSql(action, selected, props.databaseType)");
expect(queryEditorSource).toContain("enabledSqlShortcutActions(settingsStore.editorSettings.sqlShortcuts)");
expect(queryEditorSource).toContain("isCharacterProducingShortcut(action.shortcut)");
expect(queryEditorSource).toContain("uniqueSqlShortcutBindings(sqlShortcutActions)");
expect(queryEditorSource).toContain("resolveSqlShortcutForDatabase(settingsStore.editorSettings.sqlShortcuts, shortcut, props.databaseType)");
expect(queryEditorSource).toContain("isCharacterProducingShortcut(shortcut)");
expect(queryEditorSource).toContain("createQueryEditorSqlShortcutDomHandler(");
expect(queryEditorSource).toContain("() => props.databaseType");
expect(queryEditorSource).toContain("shouldBlockExecutionShortcut(event, currentView)");
expect(queryEditorSource).toContain("if (props.readOnly) return true;");
expect(queryEditorSource).toContain("settingsStore.editorSettings.sqlShortcuts");
@@ -47,7 +47,7 @@ describe("createQueryEditorSqlShortcutDomHandler", () => {
it("runs the action and prevents default when there is a selection", () => {
const runAction = vi.fn(() => true);
const handler = createQueryEditorSqlShortcutDomHandler(() => [action("a", "Shift+U")], runAction, "MacIntel");
const handler = createQueryEditorSqlShortcutDomHandler(() => [action("a", "Shift+U")], runAction, undefined, "MacIntel");
const event = keydownEvent({ key: "U", shiftKey: true });
expect(handler(event as unknown as KeyboardEvent, view)).toBe(true);
@@ -58,7 +58,7 @@ describe("createQueryEditorSqlShortcutDomHandler", () => {
it("does not intercept when there is no selection", () => {
const runAction = vi.fn(() => false);
const handler = createQueryEditorSqlShortcutDomHandler(() => [action("a", "Shift+U")], runAction, "MacIntel");
const handler = createQueryEditorSqlShortcutDomHandler(() => [action("a", "Shift+U")], runAction, undefined, "MacIntel");
const event = keydownEvent({ key: "U", shiftKey: true });
expect(handler(event as unknown as KeyboardEvent, view)).toBe(false);
@@ -69,10 +69,38 @@ describe("createQueryEditorSqlShortcutDomHandler", () => {
it("ignores non character-producing shortcuts", () => {
const runAction = vi.fn(() => true);
const handler = createQueryEditorSqlShortcutDomHandler(() => [action("a", "Mod+Shift+9")], runAction, "MacIntel");
const handler = createQueryEditorSqlShortcutDomHandler(() => [action("a", "Mod+Shift+9")], runAction, undefined, "MacIntel");
const event = keydownEvent({ key: "9", metaKey: true, shiftKey: true });
expect(handler(event as unknown as KeyboardEvent, view)).toBe(false);
expect(runAction).not.toHaveBeenCalled();
});
it("resolves the action for the active database type", () => {
const runAction = vi.fn(() => true);
const handler = createQueryEditorSqlShortcutDomHandler(
() => [action("mysql", "Shift+U", { databaseTypes: ["mysql"] }), action("sqlserver", "Shift+U", { databaseTypes: ["sqlserver"] })],
runAction,
() => "sqlserver",
"MacIntel",
);
const event = keydownEvent({ key: "U", shiftKey: true });
expect(handler(event as unknown as KeyboardEvent, view)).toBe(true);
expect(runAction).toHaveBeenCalledWith(expect.objectContaining({ id: "sqlserver" }), view, event);
});
it("does not run a scoped action on an unrelated database", () => {
const runAction = vi.fn(() => true);
const handler = createQueryEditorSqlShortcutDomHandler(
() => [action("mysql", "Shift+U", { databaseTypes: ["mysql"] })],
runAction,
() => "postgres",
"MacIntel",
);
const event = keydownEvent({ key: "U", shiftKey: true });
expect(handler(event as unknown as KeyboardEvent, view)).toBe(false);
expect(runAction).not.toHaveBeenCalled();
});
});
+220 -13
View File
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_SHORTCUT_SETTINGS } from "@/lib/editor/shortcutRegistry";
import { enabledSqlShortcutActions, findSqlShortcutConflicts, hasSqlShortcutConflicts, resolveSqlShortcutTemplate } from "@/lib/sql/sqlShortcutActions";
import { buildSelectStarWithLimitSql } from "@/lib/sql/sqlDialectSelectLimit";
import { buildSqlShortcutExecutionSql, enabledSqlShortcutActions, findSqlShortcutConflicts, hasSqlShortcutConflicts, resolveSqlShortcutForDatabase, resolveSqlShortcutTemplate, sqlShortcutAppliesToDatabase, uniqueSqlShortcutBindings } from "@/lib/sql/sqlShortcutActions";
import type { SqlShortcutAction } from "@/types/database";
function action(id: string, shortcut: string, overrides: Partial<SqlShortcutAction> = {}): SqlShortcutAction {
@@ -48,6 +49,21 @@ describe("findSqlShortcutConflicts", () => {
expect(hasSqlShortcutConflicts(actions, DEFAULT_SHORTCUT_SETTINGS)).toBe(true);
});
it("allows the same shortcut across non-overlapping database types", () => {
const actions = [action("mysql", "Mod+Shift+1", { databaseTypes: ["mysql"] }), action("sqlserver", "Mod+Shift+1", { databaseTypes: ["sqlserver"] })];
expect(findSqlShortcutConflicts(actions, DEFAULT_SHORTCUT_SETTINGS)).toEqual([]);
});
it("detects conflicts when database type scopes overlap", () => {
const actions = [action("a", "Mod+Shift+1", { databaseTypes: ["mysql", "postgres"] }), action("b", "Mod+Shift+1", { databaseTypes: ["postgres"] })];
expect(findSqlShortcutConflicts(actions, DEFAULT_SHORTCUT_SETTINGS)).toEqual(expect.arrayContaining(["a", "b"]));
});
it("treats all-databases scope as overlapping any scoped action", () => {
const actions = [action("all", "Mod+Shift+1"), action("mysql", "Mod+Shift+1", { databaseTypes: ["mysql"] })];
expect(findSqlShortcutConflicts(actions, DEFAULT_SHORTCUT_SETTINGS)).toEqual(expect.arrayContaining(["all", "mysql"]));
});
it("detects duplicate custom shortcuts with different modifier order", () => {
const actions = [action("a", "Mod+Shift+1"), action("b", "Shift+Mod+1")];
expect(findSqlShortcutConflicts(actions, DEFAULT_SHORTCUT_SETTINGS)).toEqual(expect.arrayContaining(["a", "b"]));
@@ -64,25 +80,216 @@ describe("findSqlShortcutConflicts", () => {
});
});
describe("resolveSqlShortcutForDatabase", () => {
it("prefers a scoped action over an all-databases action", () => {
const actions = [action("all", "Mod+Shift+1", { sql: "ALL" }), action("mysql", "Mod+Shift+1", { databaseTypes: ["mysql"], sql: "MYSQL" })];
expect(resolveSqlShortcutForDatabase(actions, "Mod+Shift+1", "mysql")?.id).toBe("mysql");
});
it("returns undefined when the current database is outside the scope", () => {
const actions = [action("mysql", "Mod+Shift+1", { databaseTypes: ["mysql"] })];
expect(resolveSqlShortcutForDatabase(actions, "Mod+Shift+1", "postgres")).toBeUndefined();
});
it("unique bindings collapse duplicate keys", () => {
const actions = [action("mysql", "Mod+Shift+1", { databaseTypes: ["mysql"] }), action("sqlserver", "Mod+Shift+1", { databaseTypes: ["sqlserver"] })];
expect(uniqueSqlShortcutBindings(actions)).toEqual(["Mod+Shift+1"]);
});
});
describe("buildSqlShortcutExecutionSql", () => {
it("builds dialect-aware select-limit SQL", () => {
const selectLimit = action("top10", "Mod+Shift+1", { kind: "select-limit", limit: 10 });
expect(buildSqlShortcutExecutionSql(selectLimit, "orders", "mysql")).toBe("SELECT *\nFROM orders\nLIMIT 10");
expect(buildSqlShortcutExecutionSql(selectLimit, "orders", "sqlserver")).toBe("SELECT TOP 10 *\nFROM orders");
expect(buildSqlShortcutExecutionSql(selectLimit, "orders", "oracle")).toBe("SELECT *\nFROM orders\nWHERE ROWNUM <= 10");
});
it("keeps quick actions bounded for unknown jdbc dialects", () => {
const selectLimit = action("top10", "Mod+Shift+1", { kind: "select-limit", limit: 10 });
expect(buildSqlShortcutExecutionSql(selectLimit, "orders", "jdbc")).toBe("SELECT *\nFROM orders\nLIMIT 10");
});
it("keeps plain templates unchanged aside from ${table}", () => {
expect(buildSqlShortcutExecutionSql(action("count", "Mod+C", { sql: "SELECT COUNT(*) FROM ${table}" }), "t")).toBe("SELECT COUNT(*) FROM t");
});
it("prefers sqlByDatabaseType for the active database", () => {
const scoped = action("per-db", "Mod+Shift+9", {
sql: "SELECT * FROM ${table} /* default */",
sqlByDatabaseType: {
mysql: "SELECT * FROM ${table} /* mysql */",
postgres: "SELECT * FROM ${table} /* postgres */",
},
});
expect(buildSqlShortcutExecutionSql(scoped, "orders", "mysql")).toBe("SELECT * FROM orders /* mysql */");
expect(buildSqlShortcutExecutionSql(scoped, "orders", "postgres")).toBe("SELECT * FROM orders /* postgres */");
expect(buildSqlShortcutExecutionSql(scoped, "orders", "sqlserver")).toBe("SELECT * FROM orders /* default */");
expect(buildSqlShortcutExecutionSql(scoped, "orders")).toBe("SELECT * FROM orders /* default */");
});
});
describe("isBuiltinSqlShortcut", () => {
it("recognizes shipped built-in ids", async () => {
const { isBuiltinSqlShortcut, BUILTIN_SQL_SHORTCUT_COUNT_ID, BUILTIN_SQL_SHORTCUT_SELECT_LIMIT_ID } = await import("@/lib/sql/sqlShortcutActions");
expect(isBuiltinSqlShortcut(BUILTIN_SQL_SHORTCUT_SELECT_LIMIT_ID)).toBe(true);
expect(isBuiltinSqlShortcut(BUILTIN_SQL_SHORTCUT_COUNT_ID)).toBe(true);
expect(isBuiltinSqlShortcut("custom")).toBe(false);
});
});
describe("sqlShortcutAppliesToDatabase", () => {
it("treats empty scope as all databases", () => {
expect(sqlShortcutAppliesToDatabase({}, "mysql")).toBe(true);
expect(sqlShortcutAppliesToDatabase({ databaseTypes: [] }, "mysql")).toBe(true);
});
it("requires a matching database type when scoped", () => {
expect(sqlShortcutAppliesToDatabase({ databaseTypes: ["mysql"] }, "mysql")).toBe(true);
expect(sqlShortcutAppliesToDatabase({ databaseTypes: ["mysql"] }, "postgres")).toBe(false);
expect(sqlShortcutAppliesToDatabase({ databaseTypes: ["mysql"] }, undefined)).toBe(false);
});
});
describe("buildSelectStarWithLimitSql", () => {
it("covers the main dialect styles", () => {
expect(buildSelectStarWithLimitSql("t", 5, "postgres")).toContain("LIMIT 5");
expect(buildSelectStarWithLimitSql("t", 5, "sqlserver")).toContain("TOP 5");
expect(buildSelectStarWithLimitSql("t", 5, "db2")).toContain("FETCH FIRST 5");
expect(buildSelectStarWithLimitSql("t", 5, "informix")).toContain("FIRST 5");
expect(buildSelectStarWithLimitSql("t", 5, "firebird")).toContain("ROWS 5");
expect(buildSelectStarWithLimitSql("t", 5, "jdbc")).toBe("SELECT *\nFROM t\nLIMIT 5");
});
});
describe("normalizeSqlShortcuts", () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.resetModules();
});
it("falls back to the built-in select-limit shortcut when the list is empty", async () => {
const { normalizeEditorSettings, DEFAULT_EDITOR_SETTINGS } = await import("@/stores/settingsStore");
const { DEFAULT_SQL_SHORTCUTS } = await import("@/lib/sql/sqlShortcutActions");
expect(DEFAULT_EDITOR_SETTINGS.sqlShortcuts).toEqual(DEFAULT_SQL_SHORTCUTS);
expect(normalizeEditorSettings({ sqlShortcuts: [] }).sqlShortcuts).toEqual(DEFAULT_SQL_SHORTCUTS);
expect(DEFAULT_SQL_SHORTCUTS.map((item) => item.id)).toEqual(["builtin-select-limit", "builtin-count"]);
});
it("restores defaults even when previous settings had an empty sqlShortcuts list", async () => {
const { normalizeEditorSettings } = await import("@/stores/settingsStore");
const { DEFAULT_SQL_SHORTCUTS } = await import("@/lib/sql/sqlShortcutActions");
const previous = normalizeEditorSettings({ sqlShortcuts: DEFAULT_SQL_SHORTCUTS });
previous.sqlShortcuts = [];
expect(normalizeEditorSettings({ sqlShortcuts: [] }, previous).sqlShortcuts.map((item) => item.id)).toEqual(["builtin-select-limit", "builtin-count"]);
});
it("appends missing built-in shortcuts without overwriting existing ones", async () => {
const { normalizeEditorSettings } = await import("@/stores/settingsStore");
const normalized = normalizeEditorSettings({
sqlShortcuts: [
action("builtin-select-limit", "Mod+Shift+9", {
label: "Custom top",
kind: "select-limit",
limit: 5,
sql: "ignored",
}),
],
}).sqlShortcuts;
expect(normalized.map((item) => item.id)).toEqual(["builtin-select-limit", "builtin-count"]);
expect(normalized[0]?.shortcut).toBe("Mod+Shift+9");
expect(normalized[0]?.limit).toBe(5);
expect(normalized[1]?.sql).toContain("COUNT(*)");
});
it("preserves a single shortcut through normalizeEditorSettings", async () => {
const { normalizeEditorSettings } = await import("@/stores/settingsStore");
const source = [action("count", "Mod+Shift+C", { label: "Count rows", sql: "SELECT COUNT(*) FROM ${table}" })];
const normalized = normalizeEditorSettings({ sqlShortcuts: source }).sqlShortcuts;
expect(normalized).toEqual([
{
id: "count",
label: "Count rows",
shortcut: "Mod+Shift+C",
sql: "SELECT COUNT(*) FROM ${table}",
enabled: true,
},
]);
expect(normalized[0]).toEqual({
id: "count",
label: "Count rows",
shortcut: "Mod+Shift+C",
sql: "SELECT COUNT(*) FROM ${table}",
enabled: true,
});
expect(normalized.map((item) => item.id)).toEqual(["count", "builtin-select-limit", "builtin-count"]);
});
it("normalizes select-limit kind and limit, and strips database scope", async () => {
const { normalizeEditorSettings } = await import("@/stores/settingsStore");
const normalized = normalizeEditorSettings({
sqlShortcuts: [
action("top", "Mod+Shift+1", {
kind: "select-limit",
limit: 10.8,
databaseTypes: ["mysql", "mysql", "not-a-db" as never],
sql: "ignored",
}),
],
}).sqlShortcuts;
expect(normalized[0]).toMatchObject({
id: "top",
kind: "select-limit",
limit: 10,
sql: "SELECT *\nFROM ${table}\nLIMIT 10",
});
expect(normalized[0]?.databaseTypes).toBeUndefined();
});
it("preserves sqlByDatabaseType for custom templates and strips it from select-limit", async () => {
const { normalizeEditorSettings } = await import("@/stores/settingsStore");
const normalized = normalizeEditorSettings({
sqlShortcuts: [
action("custom", "Mod+Shift+3", {
databaseTypes: ["mysql", "postgres"],
sql: "SELECT * FROM ${table} /* default */",
sqlByDatabaseType: {
mysql: "SELECT * FROM ${table} /* mysql */",
postgres: "SELECT 1 FROM ${table}",
"not-a-db": "nope",
} as never,
}),
action("top", "Mod+Shift+4", {
kind: "select-limit",
limit: 5,
sqlByDatabaseType: { mysql: "ignored" } as never,
}),
],
}).sqlShortcuts;
expect(normalized.find((item) => item.id === "custom")?.sqlByDatabaseType).toEqual({
mysql: "SELECT * FROM ${table} /* mysql */",
postgres: "SELECT 1 FROM ${table}",
});
expect(normalized.find((item) => item.id === "top")?.sqlByDatabaseType).toBeUndefined();
});
it("derives databaseTypes from sqlByDatabaseType when scope is missing", async () => {
const { normalizeEditorSettings } = await import("@/stores/settingsStore");
const { deriveSqlShortcutDatabaseTypes } = await import("@/lib/sql/sqlShortcutActions");
expect(
deriveSqlShortcutDatabaseTypes(undefined, {
mysql: "SELECT 1 FROM ${table}",
postgres: "SELECT 2 FROM ${table}",
}),
).toEqual(["mysql", "postgres"]);
const normalized = normalizeEditorSettings({
sqlShortcuts: [
action("imported", "Mod+Shift+5", {
sql: "SELECT * FROM ${table} /* default */",
sqlByDatabaseType: {
mysql: "SELECT * FROM ${table} /* mysql */",
oracle: "SELECT COUNT(*) FROM ${table}",
},
}),
],
}).sqlShortcuts;
const imported = normalized.find((item) => item.id === "imported");
expect(imported?.databaseTypes).toEqual(["mysql", "oracle"]);
expect(imported?.sqlByDatabaseType).toEqual({
mysql: "SELECT * FROM ${table} /* mysql */",
oracle: "SELECT COUNT(*) FROM ${table}",
});
});
it("keeps modifier-order variants when loading settings", async () => {
@@ -90,8 +297,8 @@ describe("normalizeSqlShortcuts", () => {
const normalized = normalizeEditorSettings({
sqlShortcuts: [action("a", "Mod+Shift+1"), action("b", "Shift+Mod+1")],
}).sqlShortcuts;
expect(normalized).toHaveLength(2);
expect(normalized.map((item) => item.id)).toEqual(["a", "b"]);
expect(normalized.map((item) => item.id).slice(0, 2)).toEqual(["a", "b"]);
expect(normalized.map((item) => item.id)).toEqual(["a", "b", "builtin-select-limit", "builtin-count"]);
});
it("drops a macOS-reserved shortcut so legacy/synced SQL shortcuts cannot hijack ⌘H", async () => {
@@ -104,7 +311,7 @@ describe("normalizeSqlShortcuts", () => {
}).sqlShortcuts;
expect(normalized.find((item) => item.id === "h")?.shortcut).toBe("");
expect(normalized.find((item) => item.id === "ok")?.shortcut).toBe("Mod+Shift+K");
expect(normalized).toHaveLength(2);
expect(normalized.map((item) => item.id)).toEqual(["h", "ok", "builtin-select-limit", "builtin-count"]);
});
it("keeps Ctrl+H on non-mac platforms where it is a legitimate SQL shortcut", async () => {
@@ -0,0 +1,126 @@
import { describe, expect, it } from "vitest";
import { applySqlShortcutBodyToAllSelected, buildSqlShortcutBodiesForSave, clearSqlShortcutFormDatabaseTypes, flushSqlShortcutEditingBody, switchSqlShortcutEditingDatabaseType, toggleSqlShortcutFormDatabaseType, type SqlShortcutFormBodies } from "@/lib/sql/sqlShortcutFormBodies";
function form(overrides: Partial<SqlShortcutFormBodies> = {}): SqlShortcutFormBodies {
return {
sql: "SELECT * FROM ${table} /* default */",
body: "SELECT * FROM ${table} /* default */",
databaseTypes: [],
sqlByDatabaseType: {},
editingDatabaseType: "",
...overrides,
};
}
describe("sqlShortcutFormBodies", () => {
it("flushes scoped edits into the override map without changing default sql", () => {
const next = flushSqlShortcutEditingBody(
form({
databaseTypes: ["mysql", "postgres"],
editingDatabaseType: "mysql",
body: "SELECT 1 FROM ${table} /* mysql */",
}),
);
expect(next.sql).toBe("SELECT * FROM ${table} /* default */");
expect(next.sqlByDatabaseType.mysql).toBe("SELECT 1 FROM ${table} /* mysql */");
});
it("switches database by flushing current and loading override or default sql", () => {
const afterMysql = flushSqlShortcutEditingBody(
form({
databaseTypes: ["mysql", "postgres"],
editingDatabaseType: "mysql",
body: "SELECT 1 FROM ${table} /* mysql */",
}),
);
const afterPostgres = switchSqlShortcutEditingDatabaseType(afterMysql, "postgres");
expect(afterPostgres.sqlByDatabaseType.mysql).toBe("SELECT 1 FROM ${table} /* mysql */");
expect(afterPostgres.body).toBe("SELECT * FROM ${table} /* default */");
expect(afterPostgres.sql).toBe("SELECT * FROM ${table} /* default */");
});
it("does not fill unedited databases on save", () => {
const edited = flushSqlShortcutEditingBody(
form({
databaseTypes: ["mysql", "postgres"],
editingDatabaseType: "mysql",
body: "SELECT 1 FROM ${table} /* mysql */",
}),
);
const saved = buildSqlShortcutBodiesForSave(edited);
expect(saved.sql).toBe("SELECT * FROM ${table} /* default */");
expect(saved.databaseTypes).toEqual(["mysql", "postgres"]);
expect(saved.sqlByDatabaseType).toEqual({ mysql: "SELECT 1 FROM ${table} /* mysql */" });
});
it("removes override when unchecking a database and does not pollute default sql", () => {
const start = form({
databaseTypes: ["mysql", "postgres"],
editingDatabaseType: "mysql",
body: "SELECT 1 FROM ${table} /* mysql */",
sqlByDatabaseType: {
mysql: "SELECT 1 FROM ${table} /* mysql */",
postgres: "SELECT 2 FROM ${table} /* postgres */",
},
});
const afterUncheck = toggleSqlShortcutFormDatabaseType(start, "mysql");
expect(afterUncheck.databaseTypes).toEqual(["postgres"]);
expect(afterUncheck.sqlByDatabaseType.mysql).toBeUndefined();
expect(afterUncheck.sqlByDatabaseType.postgres).toBe("SELECT 2 FROM ${table} /* postgres */");
expect(afterUncheck.sql).toBe("SELECT * FROM ${table} /* default */");
expect(afterUncheck.editingDatabaseType).toBe("postgres");
expect(afterUncheck.body).toBe("SELECT 2 FROM ${table} /* postgres */");
});
it("clears all overrides when returning to all databases", () => {
const start = form({
databaseTypes: ["mysql", "postgres"],
editingDatabaseType: "postgres",
body: "SELECT 2 FROM ${table} /* postgres */",
sqlByDatabaseType: {
mysql: "SELECT 1 FROM ${table} /* mysql */",
postgres: "SELECT 2 FROM ${table} /* postgres */",
},
});
const cleared = clearSqlShortcutFormDatabaseTypes(start);
expect(cleared.databaseTypes).toEqual([]);
expect(cleared.sqlByDatabaseType).toEqual({});
expect(cleared.editingDatabaseType).toBe("");
expect(cleared.sql).toBe("SELECT 2 FROM ${table} /* postgres */");
expect(cleared.body).toBe("SELECT 2 FROM ${table} /* postgres */");
});
it("does not revive overrides when re-checking a database", () => {
const afterRemove = toggleSqlShortcutFormDatabaseType(
form({
databaseTypes: ["mysql"],
editingDatabaseType: "mysql",
body: "SELECT 1 FROM ${table} /* mysql */",
sqlByDatabaseType: { mysql: "SELECT 1 FROM ${table} /* mysql */" },
}),
"mysql",
);
expect(afterRemove.databaseTypes).toEqual([]);
expect(afterRemove.sqlByDatabaseType).toEqual({});
const reAdd = toggleSqlShortcutFormDatabaseType(afterRemove, "mysql");
expect(reAdd.databaseTypes).toEqual(["mysql"]);
expect(reAdd.sqlByDatabaseType.mysql).toBeUndefined();
expect(reAdd.body).toBe(afterRemove.sql);
});
it("applies current body to all selected databases and default sql", () => {
const applied = applySqlShortcutBodyToAllSelected(
form({
databaseTypes: ["mysql", "postgres"],
editingDatabaseType: "mysql",
body: "SELECT 9 FROM ${table}",
sqlByDatabaseType: { mysql: "SELECT 1 FROM ${table}" },
}),
);
expect(applied.sql).toBe("SELECT 9 FROM ${table}");
expect(applied.sqlByDatabaseType).toEqual({
mysql: "SELECT 9 FROM ${table}",
postgres: "SELECT 9 FROM ${table}",
});
});
});
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import { resolveSqlShortcutTableToken } from "@/lib/sql/sqlShortcutTableTarget";
describe("resolveSqlShortcutTableToken", () => {
it("prefers a non-empty selection over the cursor identifier", () => {
const doc = "SELECT * FROM schema.orders WHERE id = 1";
const from = doc.indexOf("orders");
const to = from + "orders".length;
expect(
resolveSqlShortcutTableToken(doc, {
from,
to,
empty: false,
head: doc.indexOf("schema"),
}),
).toBe("orders");
});
it("uses the qualified identifier at the cursor when selection is empty", () => {
const doc = "SELECT * FROM schema.orders WHERE id = 1";
const head = doc.indexOf("orders") + 2;
expect(resolveSqlShortcutTableToken(doc, { from: head, to: head, empty: true, head })).toBe("schema.orders");
});
it("preserves original quotes in the document slice", () => {
const doc = 'SELECT * FROM "HR"."EMPLOYEES" WHERE 1=1';
const head = doc.indexOf("EMPLOYEES") + 1;
expect(resolveSqlShortcutTableToken(doc, { from: head, to: head, empty: true, head })).toBe('"HR"."EMPLOYEES"');
});
it("falls back to the previous character at a token boundary", () => {
const doc = "SELECT * FROM orders WHERE id = 1";
const head = doc.indexOf("orders") + "orders".length;
expect(resolveSqlShortcutTableToken(doc, { from: head, to: head, empty: true, head })).toBe("orders");
});
it("returns null on SQL keywords", () => {
const doc = "SELECT * FROM orders WHERE id = 1";
const head = doc.indexOf("WHERE") + 1;
expect(resolveSqlShortcutTableToken(doc, { from: head, to: head, empty: true, head })).toBeNull();
});
it("returns null when the cursor is not on an identifier", () => {
const doc = "SELECT * FROM orders WHERE id = 1";
const head = doc.indexOf("*");
expect(resolveSqlShortcutTableToken(doc, { from: head, to: head, empty: true, head })).toBeNull();
});
it("returns null for whitespace-only selection", () => {
const doc = "SELECT FROM orders";
expect(resolveSqlShortcutTableToken(doc, { from: 6, to: 8, empty: false, head: 7 })).toBeNull();
});
});
@@ -1,7 +1,7 @@
import { matchesShortcut } from "@/lib/editor/keyboardShortcuts";
import { parseShortcutParts } from "@/lib/editor/shortcutDisplay";
import { enabledSqlShortcutActions } from "@/lib/sql/sqlShortcutActions";
import type { SqlShortcutAction } from "@/types/database";
import { resolveSqlShortcutForDatabase, uniqueSqlShortcutBindings } from "@/lib/sql/sqlShortcutActions";
import type { DatabaseType, SqlShortcutAction } from "@/types/database";
// pi-lens-ignore: typescript:2307
import type { EditorView } from "@codemirror/view";
@@ -16,11 +16,19 @@ export function isCharacterProducingShortcut(shortcut: string): boolean {
return !modifiers.has("Mod") && !modifiers.has("Meta") && !modifiers.has("Ctrl") && !modifiers.has("Alt");
}
export function createQueryEditorSqlShortcutDomHandler(getActions: () => readonly SqlShortcutAction[], runAction: (action: SqlShortcutAction, view: EditorView, event: KeyboardEvent) => boolean, platform = globalThis.navigator?.platform || ""): (event: KeyboardEvent, view: EditorView) => boolean {
export function createQueryEditorSqlShortcutDomHandler(
getActions: () => readonly SqlShortcutAction[],
runAction: (action: SqlShortcutAction, view: EditorView, event: KeyboardEvent) => boolean,
getDatabaseType?: () => DatabaseType | undefined,
platform = globalThis.navigator?.platform || "",
): (event: KeyboardEvent, view: EditorView) => boolean {
return (event, view) => {
for (const action of enabledSqlShortcutActions(getActions())) {
if (!isCharacterProducingShortcut(action.shortcut)) continue;
if (!matchesShortcut(event, action.shortcut, platform)) continue;
const actions = getActions();
for (const shortcut of uniqueSqlShortcutBindings(actions, platform)) {
if (!isCharacterProducingShortcut(shortcut)) continue;
if (!matchesShortcut(event, shortcut, platform)) continue;
const action = resolveSqlShortcutForDatabase(actions, shortcut, getDatabaseType?.(), platform);
if (!action) return false;
if (!runAction(action, view, event)) return false;
event.preventDefault();
return true;
@@ -354,7 +354,17 @@ function isSqlSnippetItem(value: unknown): boolean {
function isSqlShortcutActionItem(value: unknown): boolean {
if (!isPlainObject(value)) return false;
if (!isNonEmptyTrimmedString(value.id) || !isNonEmptyTrimmedString(value.label) || typeof value.shortcut !== "string" || typeof value.sql !== "string") return false;
return value.enabled === undefined || typeof value.enabled === "boolean";
if (value.enabled !== undefined && typeof value.enabled !== "boolean") return false;
if (value.kind !== undefined && value.kind !== "template" && value.kind !== "select-limit") return false;
if (value.limit !== undefined && (typeof value.limit !== "number" || !Number.isFinite(value.limit))) return false;
if (value.databaseTypes !== undefined) {
if (!Array.isArray(value.databaseTypes) || !value.databaseTypes.every((item) => typeof item === "string" && item.trim().length > 0)) return false;
}
if (value.sqlByDatabaseType !== undefined) {
if (!isPlainObject(value.sqlByDatabaseType)) return false;
if (!Object.values(value.sqlByDatabaseType).every((sql) => typeof sql === "string")) return false;
}
return true;
}
const SHORTCUT_ACTION_IDS = new Set<string>(SHORTCUT_DEFINITIONS.map((definition) => definition.id));
@@ -0,0 +1,64 @@
import type { DatabaseType } from "@/types/database";
/** Default row limit for built-in `sel` snippets. */
export const DEFAULT_SELECT_ROW_LIMIT = 100;
/** Default row limit for SQL quick-action "select first N" kind. */
export const DEFAULT_SQL_SHORTCUT_SELECT_LIMIT = 10;
export type SelectLimitStyle = "limit" | "top" | "first" | "fetch-first" | "rows" | "rownum";
const SELECT_LIMIT_STYLE_BY_DATABASE: Partial<Record<DatabaseType, SelectLimitStyle>> = {
oracle: "rownum",
"oceanbase-oracle": "rownum",
oscar: "rownum",
dameng: "rownum",
db2: "fetch-first",
sqlserver: "top",
access: "top",
iris: "top",
teradata: "top",
informix: "first",
firebird: "rows",
// Unknown JDBC dialects fall through to LIMIT (most common / safest default).
// Known JDBC profiles are converted to their effective database type before
// reaching the editor.
};
export function selectLimitStyleForDatabase(databaseType?: DatabaseType): SelectLimitStyle {
return databaseType ? (SELECT_LIMIT_STYLE_BY_DATABASE[databaseType] ?? "limit") : "limit";
}
/**
* Build a dialect-aware `SELECT * FROM <table>` with a row limit.
* `table` is inserted as-is (caller supplies quoting / `${table}` token).
*/
export function buildSelectStarWithLimitSql(table: string, limit: number, databaseType?: DatabaseType): string {
const safeLimit = Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : DEFAULT_SELECT_ROW_LIMIT;
const style = selectLimitStyleForDatabase(databaseType);
switch (style) {
case "top":
return `SELECT TOP ${safeLimit} *\nFROM ${table}`;
case "first":
return `SELECT FIRST ${safeLimit} *\nFROM ${table}`;
case "fetch-first":
return `SELECT *\nFROM ${table}\nFETCH FIRST ${safeLimit} ROWS ONLY`;
case "rows":
return `SELECT *\nFROM ${table}\nROWS ${safeLimit}`;
case "rownum":
return `SELECT *\nFROM ${table}\nWHERE ROWNUM <= ${safeLimit}`;
case "limit":
return `SELECT *\nFROM ${table}\nLIMIT ${safeLimit}`;
}
}
/**
* Snippet bodies keep a trailing semicolon for insert-at-cursor UX.
* An unknown JDBC driver may expose any SQL dialect, so the snippet stays
* unbounded instead of guessing LIMIT; quick actions remain bounded via
* `buildSelectStarWithLimitSql` regardless.
*/
export function buildSelectSnippetBody(databaseType?: DatabaseType, limit = DEFAULT_SELECT_ROW_LIMIT): string {
if (databaseType === "jdbc") return "SELECT *\nFROM table;";
return `${buildSelectStarWithLimitSql("table", limit, databaseType)};`;
}
+144 -3
View File
@@ -1,23 +1,164 @@
import { canonicalShortcutKey } from "@/lib/editor/shortcutDisplay";
import { formatShortcut, SHORTCUT_DEFINITIONS, type ShortcutSettings } from "@/lib/editor/shortcutRegistry";
import type { SqlShortcutAction } from "@/types/database";
import { buildSelectStarWithLimitSql, DEFAULT_SQL_SHORTCUT_SELECT_LIMIT } from "@/lib/sql/sqlDialectSelectLimit";
import type { DatabaseType, SqlShortcutAction, SqlShortcutKind } from "@/types/database";
import { DATABASE_TYPES } from "@/types/generated/databaseTypes";
export const SQL_SHORTCUT_TABLE_TOKEN = "${table}";
export const BUILTIN_SQL_SHORTCUT_SELECT_LIMIT_ID = "builtin-select-limit";
export const BUILTIN_SQL_SHORTCUT_COUNT_ID = "builtin-count";
const DATABASE_TYPE_SET = new Set<string>(DATABASE_TYPES);
/** Built-in quick actions shipped with empty / missing settings. */
export const DEFAULT_SQL_SHORTCUTS: SqlShortcutAction[] = [
{
id: BUILTIN_SQL_SHORTCUT_SELECT_LIMIT_ID,
label: "Select first N rows",
shortcut: "Mod+Shift+1",
kind: "select-limit",
limit: DEFAULT_SQL_SHORTCUT_SELECT_LIMIT,
sql: buildSelectStarWithLimitSql(SQL_SHORTCUT_TABLE_TOKEN, DEFAULT_SQL_SHORTCUT_SELECT_LIMIT),
enabled: true,
},
{
id: BUILTIN_SQL_SHORTCUT_COUNT_ID,
label: "Count rows",
shortcut: "Mod+Shift+2",
sql: `SELECT COUNT(*) AS row_count FROM ${SQL_SHORTCUT_TABLE_TOKEN}`,
enabled: true,
},
];
export function isBuiltinSqlShortcut(id: string): boolean {
return id === BUILTIN_SQL_SHORTCUT_SELECT_LIMIT_ID || id === BUILTIN_SQL_SHORTCUT_COUNT_ID;
}
/** Append any missing built-in ids without overwriting user-edited entries. */
export function mergeDefaultSqlShortcuts(actions: readonly SqlShortcutAction[]): SqlShortcutAction[] {
const ids = new Set(actions.map((action) => action.id));
const missing = DEFAULT_SQL_SHORTCUTS.filter((action) => !ids.has(action.id)).map((action) => ({ ...action }));
return missing.length > 0 ? [...actions, ...missing] : [...actions];
}
export function resolveSqlShortcutTemplate(template: string, selectedTable: string): string {
return template.replace(/\$\{table\}/g, () => selectedTable.trim());
}
export function normalizeSqlShortcutLimit(value: unknown, fallback = DEFAULT_SQL_SHORTCUT_SELECT_LIMIT): number {
const n = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN;
if (!Number.isFinite(n) || n < 1) return fallback;
return Math.min(Math.floor(n), 100_000);
}
export function normalizeSqlShortcutKind(value: unknown): SqlShortcutKind {
return value === "select-limit" ? "select-limit" : "template";
}
export function normalizeSqlShortcutDatabaseTypes(value: unknown): DatabaseType[] | undefined {
if (!Array.isArray(value) || value.length === 0) return undefined;
const valid: DatabaseType[] = [];
const seen = new Set<string>();
for (const item of value) {
if (typeof item !== "string" || !DATABASE_TYPE_SET.has(item) || seen.has(item)) continue;
seen.add(item);
valid.push(item as DatabaseType);
}
return valid.length > 0 ? valid : undefined;
}
export function normalizeSqlShortcutSqlByDatabaseType(value: unknown): Partial<Record<DatabaseType, string>> | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
const result: Partial<Record<DatabaseType, string>> = {};
for (const [key, sql] of Object.entries(value as Record<string, unknown>)) {
if (!DATABASE_TYPE_SET.has(key) || typeof sql !== "string") continue;
result[key as DatabaseType] = sql;
}
return Object.keys(result).length > 0 ? result : undefined;
}
/** When overrides exist without an explicit scope, treat override keys as the database scope. */
export function deriveSqlShortcutDatabaseTypes(databaseTypes: DatabaseType[] | undefined, sqlByDatabaseType: Partial<Record<DatabaseType, string>> | undefined): DatabaseType[] | undefined {
if (databaseTypes && databaseTypes.length > 0) return databaseTypes;
if (!sqlByDatabaseType) return undefined;
const derived = Object.keys(sqlByDatabaseType).filter((key): key is DatabaseType => DATABASE_TYPE_SET.has(key));
return derived.length > 0 ? derived : undefined;
}
export function resolveSqlShortcutBody(action: Pick<SqlShortcutAction, "sql" | "sqlByDatabaseType">, databaseType?: DatabaseType): string {
if (databaseType && action.sqlByDatabaseType?.[databaseType] != null && action.sqlByDatabaseType[databaseType] !== "") {
return action.sqlByDatabaseType[databaseType]!;
}
return action.sql;
}
export function canonicalSqlShortcutSql(action: Pick<SqlShortcutAction, "kind" | "limit" | "sql">): string {
if (normalizeSqlShortcutKind(action.kind) !== "select-limit") return action.sql;
const limit = normalizeSqlShortcutLimit(action.limit);
return buildSelectStarWithLimitSql(SQL_SHORTCUT_TABLE_TOKEN, limit);
}
export function enabledSqlShortcutActions(actions: readonly SqlShortcutAction[]): SqlShortcutAction[] {
return actions.filter((action) => action.enabled !== false && action.shortcut.trim().length > 0);
}
function shortcutsUseSameKeys(first: string, second: string, platform = globalThis.navigator?.platform || ""): boolean {
export function sqlShortcutAppliesToDatabase(action: Pick<SqlShortcutAction, "databaseTypes">, databaseType?: DatabaseType): boolean {
const types = action.databaseTypes;
if (!types || types.length === 0) return true;
if (!databaseType) return false;
return types.includes(databaseType);
}
export function sqlShortcutDatabaseScopesOverlap(first?: readonly DatabaseType[], second?: readonly DatabaseType[]): boolean {
const firstAll = !first || first.length === 0;
const secondAll = !second || second.length === 0;
if (firstAll || secondAll) return true;
const secondSet = new Set(second);
return first.some((dbType) => secondSet.has(dbType));
}
export function shortcutsUseSameKeys(first: string, second: string, platform = globalThis.navigator?.platform || ""): boolean {
if (!first || !second) return false;
if (canonicalShortcutKey(first) === canonicalShortcutKey(second)) return true;
return formatShortcut(first, platform).toLowerCase() === formatShortcut(second, platform).toLowerCase();
}
export function uniqueSqlShortcutBindings(actions: readonly SqlShortcutAction[], platform = globalThis.navigator?.platform || ""): string[] {
const bindings: string[] = [];
for (const action of enabledSqlShortcutActions(actions)) {
const shortcut = action.shortcut.trim();
if (bindings.some((existing) => shortcutsUseSameKeys(existing, shortcut, platform))) continue;
bindings.push(shortcut);
}
return bindings;
}
/**
* Prefer a scoped action over an all-databases action when both match the same key.
*/
export function resolveSqlShortcutForDatabase(actions: readonly SqlShortcutAction[], shortcut: string, databaseType?: DatabaseType, platform = globalThis.navigator?.platform || ""): SqlShortcutAction | undefined {
const matches = enabledSqlShortcutActions(actions).filter((action) => shortcutsUseSameKeys(action.shortcut, shortcut, platform) && sqlShortcutAppliesToDatabase(action, databaseType));
if (matches.length === 0) return undefined;
matches.sort((a, b) => (b.databaseTypes?.length ?? 0) - (a.databaseTypes?.length ?? 0));
return matches[0];
}
export function buildSqlShortcutExecutionSql(action: SqlShortcutAction, selectedTable: string, databaseType?: DatabaseType): string {
const table = selectedTable.trim();
if (normalizeSqlShortcutKind(action.kind) === "select-limit") {
return buildSelectStarWithLimitSql(table, normalizeSqlShortcutLimit(action.limit), databaseType);
}
return resolveSqlShortcutTemplate(resolveSqlShortcutBody(action, databaseType), table);
}
export function sqlShortcutDisplaySql(action: SqlShortcutAction, databaseType?: DatabaseType): string {
if (normalizeSqlShortcutKind(action.kind) === "select-limit") {
return buildSelectStarWithLimitSql(SQL_SHORTCUT_TABLE_TOKEN, normalizeSqlShortcutLimit(action.limit), databaseType);
}
return resolveSqlShortcutBody(action, databaseType);
}
export function findSqlShortcutConflicts(actions: readonly SqlShortcutAction[], fixedShortcuts: ShortcutSettings, platform = globalThis.navigator?.platform || ""): string[] {
const conflicts = new Set<string>();
const editorFixedShortcuts = SHORTCUT_DEFINITIONS.filter((item) => item.scope === "editor" || item.scope === "global");
@@ -25,7 +166,7 @@ export function findSqlShortcutConflicts(actions: readonly SqlShortcutAction[],
for (const action of actions) {
if (action.enabled === false || !action.shortcut.trim()) continue;
const duplicate = actions.find((other) => other.id !== action.id && other.enabled !== false && other.shortcut.trim() && shortcutsUseSameKeys(other.shortcut, action.shortcut, platform));
const duplicate = actions.find((other) => other.id !== action.id && other.enabled !== false && other.shortcut.trim() && shortcutsUseSameKeys(other.shortcut, action.shortcut, platform) && sqlShortcutDatabaseScopesOverlap(action.databaseTypes, other.databaseTypes));
if (duplicate) {
conflicts.add(action.id);
conflicts.add(duplicate.id);
@@ -0,0 +1,123 @@
import type { DatabaseType } from "@/types/database";
/** In-dialog state for custom SQL shortcut bodies (fallback + per-DB overrides). */
export type SqlShortcutFormBodies = {
/** Default fallback template persisted as payload.sql */
sql: string;
/** Textarea content for the current editing context */
body: string;
databaseTypes: DatabaseType[];
sqlByDatabaseType: Partial<Record<DatabaseType, string>>;
editingDatabaseType: DatabaseType | "";
};
/** Stage the current textarea into the override map (scoped) or default sql (all-databases). */
export function flushSqlShortcutEditingBody(form: SqlShortcutFormBodies): SqlShortcutFormBodies {
if (!form.editingDatabaseType) {
return { ...form, sql: form.body, sqlByDatabaseType: { ...form.sqlByDatabaseType } };
}
return {
...form,
sqlByDatabaseType: { ...form.sqlByDatabaseType, [form.editingDatabaseType]: form.body },
};
}
/** Flush current body, then load textarea for another selected database. */
export function switchSqlShortcutEditingDatabaseType(form: SqlShortcutFormBodies, next: DatabaseType): SqlShortcutFormBodies {
const flushed = flushSqlShortcutEditingBody(form);
return {
...flushed,
editingDatabaseType: next,
body: flushed.sqlByDatabaseType[next] ?? flushed.sql,
};
}
export function toggleSqlShortcutFormDatabaseType(form: SqlShortcutFormBodies, dbType: DatabaseType): SqlShortcutFormBodies {
const flushed = flushSqlShortcutEditingBody(form);
const removing = flushed.databaseTypes.includes(dbType);
const nextTypes = removing ? flushed.databaseTypes.filter((item) => item !== dbType) : [...flushed.databaseTypes, dbType];
const sqlByDatabaseType = { ...flushed.sqlByDatabaseType };
if (removing) {
delete sqlByDatabaseType[dbType];
}
if (nextTypes.length === 0) {
// Last scoped DB removed → same as "all databases": keep visible text as the only sql.
const body = flushed.editingDatabaseType === dbType || !flushed.editingDatabaseType ? flushed.body : (sqlByDatabaseType[flushed.editingDatabaseType] ?? flushed.body);
return {
...flushed,
databaseTypes: [],
sqlByDatabaseType: {},
editingDatabaseType: "",
sql: body,
body,
};
}
let editingDatabaseType = flushed.editingDatabaseType;
if (!editingDatabaseType || !nextTypes.includes(editingDatabaseType)) {
editingDatabaseType = nextTypes[0]!;
}
return {
...flushed,
databaseTypes: nextTypes,
sqlByDatabaseType,
editingDatabaseType,
body: sqlByDatabaseType[editingDatabaseType] ?? flushed.sql,
sql: flushed.sql,
};
}
/** Switch back to all-databases: wipe overrides, keep currently visible text as sql. */
export function clearSqlShortcutFormDatabaseTypes(form: SqlShortcutFormBodies): SqlShortcutFormBodies {
const flushed = flushSqlShortcutEditingBody(form);
const body = flushed.editingDatabaseType && flushed.sqlByDatabaseType[flushed.editingDatabaseType] != null ? flushed.sqlByDatabaseType[flushed.editingDatabaseType]! : flushed.body;
return {
...flushed,
databaseTypes: [],
sqlByDatabaseType: {},
editingDatabaseType: "",
sql: body,
body,
};
}
/** Copy current textarea to every selected DB override and the default sql. */
export function applySqlShortcutBodyToAllSelected(form: SqlShortcutFormBodies): SqlShortcutFormBodies {
if (form.databaseTypes.length === 0) {
return { ...form, sql: form.body, sqlByDatabaseType: {} };
}
const sqlByDatabaseType: Partial<Record<DatabaseType, string>> = {};
for (const dbType of form.databaseTypes) {
sqlByDatabaseType[dbType] = form.body;
}
return {
...form,
sql: form.body,
sqlByDatabaseType,
};
}
/** Persistable bodies: default sql + overrides only for still-selected databases. */
export function buildSqlShortcutBodiesForSave(form: SqlShortcutFormBodies): {
sql: string;
databaseTypes?: DatabaseType[];
sqlByDatabaseType?: Partial<Record<DatabaseType, string>>;
} {
const flushed = flushSqlShortcutEditingBody(form);
if (flushed.databaseTypes.length === 0) {
return { sql: flushed.body };
}
const sqlByDatabaseType: Partial<Record<DatabaseType, string>> = {};
for (const dbType of flushed.databaseTypes) {
const override = flushed.sqlByDatabaseType[dbType];
if (override != null) sqlByDatabaseType[dbType] = override;
}
return {
sql: flushed.sql,
databaseTypes: [...flushed.databaseTypes],
...(Object.keys(sqlByDatabaseType).length > 0 ? { sqlByDatabaseType } : {}),
};
}
@@ -0,0 +1,35 @@
import { extractQualifiedIdentifierAt, isSqlKeyword } from "@/lib/sql/sqlNavigation";
export type SqlShortcutTableSelection = {
from: number;
to: number;
empty: boolean;
/** Cursor / selection head used when selection is empty. */
head: number;
};
function identifierAtCursor(doc: string, pos: number): { start: number; end: number } | null {
let located = extractQualifiedIdentifierAt(doc, pos);
// CodeMirror can report the boundary immediately after the token.
if (!located && pos > 0) located = extractQualifiedIdentifierAt(doc, pos - 1);
if (!located || located.parts.length === 0) return null;
const last = located.parts[located.parts.length - 1]!;
if (!last.quoted && isSqlKeyword(last.value)) return null;
return { start: located.start, end: located.end };
}
/**
* Resolve the `${table}` token for SQL quick actions:
* non-empty selection wins; otherwise use the qualified identifier under the cursor
* (preserving original quotes and dots from the document).
*/
export function resolveSqlShortcutTableToken(doc: string, selection: SqlShortcutTableSelection): string | null {
if (!selection.empty) {
const selected = doc.slice(selection.from, selection.to).trim();
return selected.length > 0 ? selected : null;
}
const located = identifierAtCursor(doc, selection.head);
if (!located) return null;
const token = doc.slice(located.start, located.end).trim();
return token.length > 0 ? token : null;
}
@@ -1,7 +1,6 @@
import { buildSelectSnippetBody, DEFAULT_SELECT_ROW_LIMIT } from "@/lib/sql/sqlDialectSelectLimit";
import type { DatabaseType, SqlSnippet } from "@/types/database";
const DEFAULT_SELECT_ROW_LIMIT = 100;
/**
* Built-in SQL snippets shared by database types.
*
@@ -129,48 +128,9 @@ interface BuiltinSqlSnippetRule {
buildBody: BuiltinSqlSnippetBodyBuilder;
}
type SelectSnippetLimitStyle = "limit" | "top" | "first" | "fetch-first" | "rows" | "rownum" | "unbounded";
const SELECT_SNIPPET_LIMIT_STYLE_BY_DATABASE: Partial<Record<DatabaseType, SelectSnippetLimitStyle>> = {
oracle: "rownum",
"oceanbase-oracle": "rownum",
oscar: "rownum",
dameng: "rownum",
db2: "fetch-first",
sqlserver: "top",
access: "top",
iris: "top",
teradata: "top",
informix: "first",
firebird: "rows",
// An unknown JDBC driver may expose any SQL dialect. Known JDBC profiles are
// converted to their effective database type before reaching the editor.
jdbc: "unbounded",
};
const PARENTHESIZED_ADD_COLUMN_DATABASES = new Set<DatabaseType>(["oracle", "oceanbase-oracle", "yashandb", "xugu", "dameng", "iris", "informix"]);
const ADD_COLUMN_WITHOUT_COLUMN_KEYWORD_DATABASES = new Set<DatabaseType>(["sqlserver", "kingbase", "cassandra", "teradata"]);
function buildSelectSnippetBody(databaseType?: DatabaseType): string {
const style = databaseType ? (SELECT_SNIPPET_LIMIT_STYLE_BY_DATABASE[databaseType] ?? "limit") : "limit";
switch (style) {
case "top":
return `SELECT TOP ${DEFAULT_SELECT_ROW_LIMIT} *\nFROM table;`;
case "first":
return `SELECT FIRST ${DEFAULT_SELECT_ROW_LIMIT} *\nFROM table;`;
case "fetch-first":
return `SELECT *\nFROM table\nFETCH FIRST ${DEFAULT_SELECT_ROW_LIMIT} ROWS ONLY;`;
case "rows":
return `SELECT *\nFROM table\nROWS ${DEFAULT_SELECT_ROW_LIMIT};`;
case "rownum":
return `SELECT *\nFROM table\nWHERE ROWNUM <= ${DEFAULT_SELECT_ROW_LIMIT};`;
case "unbounded":
return "SELECT *\nFROM table;";
case "limit":
return `SELECT *\nFROM table\nLIMIT ${DEFAULT_SELECT_ROW_LIMIT};`;
}
}
function buildUpdateSnippetBody(databaseType?: DatabaseType): string {
if (databaseType === "clickhouse") {
return "ALTER TABLE table\nUPDATE column = value\nWHERE condition;";
+20 -4
View File
@@ -24,6 +24,7 @@ import { normalizeSidebarCopyTableNameSeparator } from "@/lib/sidebar/sidebarTab
import type { SidebarActivation } from "@/lib/sidebar/treeNodeClick";
import { DEFAULT_SQL_SNIPPETS } from "@/lib/sql/sqlCompletion";
import { DEFAULT_SQL_FORMATTER_SETTINGS, normalizeSqlFormatterSettings, type SqlFormatterSettings } from "@/lib/sql/sqlFormatterConfig";
import { canonicalSqlShortcutSql, DEFAULT_SQL_SHORTCUTS, deriveSqlShortcutDatabaseTypes, mergeDefaultSqlShortcuts, normalizeSqlShortcutDatabaseTypes, normalizeSqlShortcutKind, normalizeSqlShortcutLimit, normalizeSqlShortcutSqlByDatabaseType } from "@/lib/sql/sqlShortcutActions";
import { normalizeSqlVariableSyntaxOverrides, type SqlVariableSyntaxOverrides } from "@/lib/sql/sqlVariableSyntax";
import { DEFAULT_TABLE_COLUMN_TEMPLATE_FIELDS, normalizeTableColumnTemplateFields } from "@/lib/table/tableColumnTemplates";
import { type DataTabReuseMode, DEFAULT_DATA_TAB_REUSE_MODE, normalizeDataTabReuseMode } from "@/lib/tabs/dataTabReuseMode";
@@ -1121,7 +1122,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = {
globalDateTimeExportFormat: "",
globalDateTimeImportFormat: "",
snippets: DEFAULT_SQL_SNIPPETS,
sqlShortcuts: [],
sqlShortcuts: DEFAULT_SQL_SHORTCUTS,
tableColumnTemplateFields: [...DEFAULT_TABLE_COLUMN_TEMPLATE_FIELDS],
exportBatchSize: 2000,
csvQuoteMode: DEFAULT_CSV_QUOTE_MODE,
@@ -1367,15 +1368,30 @@ function normalizeSqlShortcuts(value: unknown, existing?: SqlShortcutAction[]):
// 与普通动作一样会重新劫持 macOS 的 ⌘H。此处直接丢弃保留组合——SQL 快捷键
// 没有“平台默认值”这一概念(它是用户自定义模板的专属触发键),清空即视为未绑定。
const normalizedShortcut = isReservedShortcut(shortcut) ? "" : shortcut;
valid.push({
const kind = normalizeSqlShortcutKind(item.kind);
let databaseTypes = normalizeSqlShortcutDatabaseTypes(item.databaseTypes);
const entry: SqlShortcutAction = {
id: item.id,
label: item.label,
shortcut: normalizedShortcut,
sql: item.sql,
enabled: item.enabled !== false,
});
};
const sqlByDatabaseType = normalizeSqlShortcutSqlByDatabaseType(item.sqlByDatabaseType);
if (sqlByDatabaseType && kind !== "select-limit") entry.sqlByDatabaseType = sqlByDatabaseType;
databaseTypes = deriveSqlShortcutDatabaseTypes(databaseTypes, entry.sqlByDatabaseType);
if (databaseTypes) entry.databaseTypes = databaseTypes;
if (kind === "select-limit") {
entry.kind = "select-limit";
entry.limit = normalizeSqlShortcutLimit(item.limit);
entry.sql = canonicalSqlShortcutSql(entry);
delete entry.databaseTypes;
delete entry.sqlByDatabaseType;
}
valid.push(entry);
}
return valid;
if (valid.length === 0) return DEFAULT_SQL_SHORTCUTS.map((action) => ({ ...action }));
return mergeDefaultSqlShortcuts(valid);
}
function normalizeToolbarItems(items: Partial<ToolbarItems> | undefined): ToolbarItems {
+10
View File
@@ -21,12 +21,22 @@ export interface SqlSnippet {
enabled?: boolean;
}
export type SqlShortcutKind = "template" | "select-limit";
export interface SqlShortcutAction {
id: string;
label: string;
shortcut: string;
sql: string;
enabled?: boolean;
/** Empty / omitted = all databases. Non-empty = only these DatabaseType values. */
databaseTypes?: DatabaseType[];
/** Per-database SQL overrides for custom templates; missing keys fall back to `sql`. */
sqlByDatabaseType?: Partial<Record<DatabaseType, string>>;
/** `select-limit` builds dialect-aware SELECT * … LIMIT/TOP/ROWNUM at run time (built-in only). */
kind?: SqlShortcutKind;
/** Row count for `select-limit` (default 10). Ignored for plain templates. */
limit?: number;
}
export type CompletionAssistantObjectKind = "database" | "schema" | "table" | "view" | "routine" | "procedure" | "function" | "column" | "sequence";