feat(editor): dialect-aware SQL quick actions with per-database templates
This commit is contained in:
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;
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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インスタンス内に保持されます。",
|
||||
|
||||
@@ -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 인스턴스에 유지됩니다.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 实例中。",
|
||||
|
||||
@@ -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
@@ -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)};`;
|
||||
}
|
||||
@@ -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;";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in new issue
Block a user