feat(structure): add configurable preset fields for new tables

This commit is contained in:
Spencer.Chang 2026-06-25 01:05:36 +08:00 committed by GitHub
parent cc2fa698d2
commit 529454e6d1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 750 additions and 6 deletions

View File

@ -111,6 +111,8 @@ const setupRequired = ref(false);
const showConnectionDialog = ref(false);
const connectionDialogPrefill = ref<ConnectionDeepLinkDraft | null>(null);
const showSettingsDialog = ref(false);
const settingsInitialTab = ref("editor");
const settingsInitialSection = ref<string | undefined>(undefined);
const showQueryEditorDdlDialog = ref(false);
const showDriverStore = ref(false);
const showQuickOpen = ref(false);
@ -232,6 +234,12 @@ useVisibilityChange();
const appVersion = ref("");
const isClassicLayout = computed(() => settingsStore.editorSettings.appLayout === "classic");
const updateNotificationsEnabled = computed(() => settingsStore.editorSettings.updateNotificationsEnabled);
function openSettings(initialTab = "editor", initialSection?: string) {
settingsInitialTab.value = initialTab;
settingsInitialSection.value = initialSection;
showSettingsDialog.value = true;
}
const toolbarAgentDriverUpdateCount = computed(() => (updateNotificationsEnabled.value ? agentDriverUpdateCount.value : 0));
const toolbarHasUpdateAvailable = computed(() => updateNotificationsEnabled.value && hasUpdateAvailable.value);
const hasSqlFileConnections = computed(() => connectionStore.connections.some((c) => supportsSqlFileExecution(c.db_type)));
@ -1140,7 +1148,7 @@ function handleKeydown(e: KeyboardEvent) {
if (isOpenSettingsShortcut(e, shortcuts)) {
e.preventDefault();
e.stopPropagation();
showSettingsDialog.value = true;
openSettings();
return;
}
if (isQuickOpenShortcut(e, shortcuts)) {
@ -1427,7 +1435,7 @@ onUnmounted(() => {
@toggle-history="showHistory = !showHistory"
@toggle-sql-library="toggleSqlLibrary"
@open-github="openGitHub"
@open-settings="showSettingsDialog = true"
@open-settings="openSettings()"
@open-driver-store="showDriverStore = !showDriverStore"
@check-updates="checkUpdates()"
@open-transfer="dialogs.showTransferDialog.value = true"
@ -1530,6 +1538,7 @@ onUnmounted(() => {
)
"
@structure-editor-close="activeTab && queryStore.closeTab(activeTab.id)"
@open-settings="openSettings"
/>
</KeepAlive>
</div>
@ -1576,6 +1585,8 @@ onUnmounted(() => {
:show-connection-dialog="showConnectionDialog"
:connection-prefill="connectionDialogPrefill"
:show-settings-dialog="showSettingsDialog"
:settings-initial-tab="settingsInitialTab"
:settings-initial-section="settingsInitialSection"
:app-version="appVersion"
:show-danger-dialog="showDangerDialog"
:danger-sql="dangerSql"

View File

@ -3,7 +3,7 @@ import { ref, watch, shallowRef, computed, onMounted, onUnmounted, nextTick } fr
import type { Ref } from "vue";
import type { EditorView as EditorViewType } from "@codemirror/view";
import { useI18n } from "vue-i18n";
import { AlertTriangle, CheckCircle2, CircleHelp, Cloud, Copy, Download, ExternalLink, Loader2, Moon, PackageSearch, Pencil, RefreshCw, RotateCcw, Settings, Sun, SunMoon, Terminal, Trash2, Upload, X } from "@lucide/vue";
import { AlertTriangle, CheckCircle2, CircleHelp, Cloud, Copy, Download, ExternalLink, GripVertical, Loader2, Moon, PackageSearch, Pencil, RefreshCw, RotateCcw, Settings, Sun, SunMoon, Terminal, Trash2, Upload, X } from "@lucide/vue";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
@ -62,7 +62,9 @@ import { eventToShortcut } from "@/lib/keyboardShortcuts";
import { SHORTCUT_DEFINITIONS, findShortcutConflict, normalizeShortcutSettings, type ShortcutActionId } from "@/lib/shortcutRegistry";
import { normalizeSidebarHiddenTablePrefixes } from "@/lib/sidebarTableNameDisplay";
import { normalizeSqlFormatterSettings, type SqlFormatterSettings } from "@/lib/sqlFormatterConfig";
import type { SqlSnippet } from "@/types/database";
import { EMPTY_TABLE_COLUMN_TEMPLATE_DATA_TYPE, parseTableColumnTemplateFields, TABLE_COLUMN_TEMPLATE_DATABASE_TYPES } from "@/lib/tableColumnTemplates";
import { combineDataTypeForDatabase, getDataTypeOptions, getDefaultLengthForType, isDataTypeLengthDisabled, splitDataType } from "@/lib/tableStructureEditorState";
import type { DatabaseType, SqlSnippet } from "@/types/database";
import { uuid } from "@/lib/utils";
import { DEFAULT_SQL_SNIPPETS } from "@/lib/sqlCompletion";
import AiProviderLogo from "@/components/icons/AiProviderLogo.vue";
@ -86,6 +88,7 @@ let pendingSystemFonts: Promise<string[]> | null = null;
const props = defineProps<{
open: boolean;
initialTab?: string;
initialSection?: string;
appVersion?: string;
}>();
@ -93,6 +96,76 @@ const emit = defineEmits<{
"update:open": [value: boolean];
}>();
interface TableColumnTemplateOverrideRow {
id: string;
databaseType: DatabaseType;
dataType: string;
}
interface TableColumnTemplateGridRow {
id: string;
name: string;
defaultValue: string;
required: boolean;
comment: string;
overrides: TableColumnTemplateOverrideRow[];
}
function tableColumnTemplateRowsFromSettings(lines: readonly string[]): TableColumnTemplateGridRow[] {
return parseTableColumnTemplateFields([...lines]).map((field) => ({
id: uuid(),
name: field.name,
defaultValue: field.defaultValue ?? "",
required: !(field.isNullable ?? false),
comment: field.comment ?? "",
overrides: Object.entries(field.dataTypesByDatabase).map(([databaseType, dataType]) => ({
id: uuid(),
databaseType: databaseType as DatabaseType,
dataType: dataType === EMPTY_TABLE_COLUMN_TEMPLATE_DATA_TYPE ? "" : dataType,
})),
}));
}
function tableColumnTemplateRowsToSettings(rows: readonly TableColumnTemplateGridRow[]): string[] {
const seenNames = new Set<string>();
const settings: string[] = [];
for (const row of rows) {
const name = row.name.trim();
if (!name) continue;
const key = name.toLowerCase();
if (seenNames.has(key)) continue;
seenNames.add(key);
const parts = [name];
const seenDatabaseTypes = new Set<DatabaseType>();
for (const override of row.overrides) {
const dataType = override.dataType.trim();
if (seenDatabaseTypes.has(override.databaseType)) continue;
seenDatabaseTypes.add(override.databaseType);
parts.push(`${override.databaseType}:${dataType || EMPTY_TABLE_COLUMN_TEMPLATE_DATA_TYPE}`);
}
if (!row.required) parts.push("required:false");
const defaultValue = row.defaultValue.trim();
if (defaultValue) parts.push(`default:${defaultValue}`);
const comment = row.comment.trim();
if (comment) parts.push(`comment:${comment}`);
settings.push(parts.join(" | "));
}
return settings;
}
function createEmptyTableColumnTemplateRow(): TableColumnTemplateGridRow {
return {
id: uuid(),
name: "",
defaultValue: "",
required: true,
comment: "",
overrides: [],
};
}
// Local edit state
const editFontFamily = ref(settingsStore.editorSettings.fontFamily);
const editFontSize = ref(settingsStore.editorSettings.fontSize);
@ -120,6 +193,11 @@ const editShowColumnTypesInHeader = ref(settingsStore.editorSettings.showColumnT
const editCompactColumnHeaderActions = ref(settingsStore.editorSettings.compactColumnHeaderActions);
const editInfiniteScroll = ref(settingsStore.editorSettings.infiniteScroll);
const editInfiniteScrollMaxRows = ref(settingsStore.editorSettings.infiniteScrollMaxRows);
const editTableColumnTemplateRows = ref<TableColumnTemplateGridRow[]>(tableColumnTemplateRowsFromSettings(settingsStore.editorSettings.tableColumnTemplateFields));
const editTableColumnTemplateDatabaseType = ref<DatabaseType>(TABLE_COLUMN_TEMPLATE_DATABASE_TYPES[0] ?? "mysql");
const tableColumnTemplateSectionRef = ref<HTMLElement | null>(null);
const draggedTableColumnTemplateRowId = ref<string | null>(null);
let tableColumnTemplatePointerDragCleanup: (() => void) | null = null;
const editRedisScanPageSize = ref(settingsStore.editorSettings.redisScanPageSize);
const editShortcuts = ref(normalizeShortcutSettings(settingsStore.editorSettings.shortcuts));
const editSqlFormatter = ref<SqlFormatterSettings>(normalizeSqlFormatterSettings(settingsStore.editorSettings.sqlFormatter));
@ -157,6 +235,13 @@ const disconnectTabHandlingModeDescriptionKey = computed(() => {
return "disconnectTabHandlingModeCloseTabsDescription";
});
const normalizedEditTableColumnTemplateFields = computed(() => tableColumnTemplateRowsToSettings(editTableColumnTemplateRows.value));
const visibleTableColumnTemplateRows = computed(() =>
editTableColumnTemplateRows.value.filter((row) => {
if (row.overrides.length === 0) return true;
return row.overrides.some((override) => override.databaseType === editTableColumnTemplateDatabaseType.value);
}),
);
// --- Snippet state ---
const editSnippets = ref<SqlSnippet[]>(settingsStore.editorSettings.snippets.map((s) => ({ ...s })));
@ -383,6 +468,7 @@ watch(
editCompactColumnHeaderActions.value = settingsStore.editorSettings.compactColumnHeaderActions;
editInfiniteScroll.value = settingsStore.editorSettings.infiniteScroll;
editInfiniteScrollMaxRows.value = settingsStore.editorSettings.infiniteScrollMaxRows;
editTableColumnTemplateRows.value = tableColumnTemplateRowsFromSettings(settingsStore.editorSettings.tableColumnTemplateFields);
editRedisScanPageSize.value = settingsStore.editorSettings.redisScanPageSize;
editShortcuts.value = normalizeShortcutSettings(settingsStore.editorSettings.shortcuts);
editSqlFormatter.value = normalizeSqlFormatterSettings(settingsStore.editorSettings.sqlFormatter);
@ -445,6 +531,7 @@ function hasChanges(): boolean {
editCompactColumnHeaderActions.value !== settingsStore.editorSettings.compactColumnHeaderActions ||
editInfiniteScroll.value !== settingsStore.editorSettings.infiniteScroll ||
editInfiniteScrollMaxRows.value !== settingsStore.editorSettings.infiniteScrollMaxRows ||
JSON.stringify(normalizedEditTableColumnTemplateFields.value) !== JSON.stringify(settingsStore.editorSettings.tableColumnTemplateFields) ||
editRedisScanPageSize.value !== settingsStore.editorSettings.redisScanPageSize ||
JSON.stringify(editShortcuts.value) !== JSON.stringify(settingsStore.editorSettings.shortcuts) ||
JSON.stringify(editSqlFormatter.value) !== JSON.stringify(normalizeSqlFormatterSettings(settingsStore.editorSettings.sqlFormatter)) ||
@ -487,6 +574,7 @@ async function persistSettings() {
compactColumnHeaderActions: editCompactColumnHeaderActions.value,
infiniteScroll: editInfiniteScroll.value,
infiniteScrollMaxRows: editInfiniteScrollMaxRows.value,
tableColumnTemplateFields: normalizedEditTableColumnTemplateFields.value,
redisScanPageSize: editRedisScanPageSize.value,
shortcuts: editShortcuts.value,
sqlFormatter: normalizeSqlFormatterSettings(editSqlFormatter.value),
@ -570,6 +658,7 @@ function resetDefaultsForTab(tab: SettingsCategory) {
editCompactColumnHeaderActions.value = DEFAULT_EDITOR_SETTINGS.compactColumnHeaderActions;
editInfiniteScroll.value = DEFAULT_EDITOR_SETTINGS.infiniteScroll;
editInfiniteScrollMaxRows.value = DEFAULT_EDITOR_SETTINGS.infiniteScrollMaxRows;
editTableColumnTemplateRows.value = tableColumnTemplateRowsFromSettings(DEFAULT_EDITOR_SETTINGS.tableColumnTemplateFields);
editExportBatchSize.value = DEFAULT_EDITOR_SETTINGS.exportBatchSize;
editExportRowLimitEnabled.value = DEFAULT_EDITOR_SETTINGS.exportRowLimitEnabled;
editExportRowLimit.value = DEFAULT_EDITOR_SETTINGS.exportRowLimit;
@ -607,6 +696,7 @@ function resetAllDefaults() {
editCompactColumnHeaderActions.value = DEFAULT_EDITOR_SETTINGS.compactColumnHeaderActions;
editInfiniteScroll.value = DEFAULT_EDITOR_SETTINGS.infiniteScroll;
editInfiniteScrollMaxRows.value = DEFAULT_EDITOR_SETTINGS.infiniteScrollMaxRows;
editTableColumnTemplateRows.value = tableColumnTemplateRowsFromSettings(DEFAULT_EDITOR_SETTINGS.tableColumnTemplateFields);
editRedisScanPageSize.value = DEFAULT_EDITOR_SETTINGS.redisScanPageSize;
editShortcuts.value = normalizeShortcutSettings(DEFAULT_EDITOR_SETTINGS.shortcuts);
editSqlFormatter.value = normalizeSqlFormatterSettings(DEFAULT_EDITOR_SETTINGS.sqlFormatter);
@ -628,6 +718,130 @@ function resetAllDefaults() {
editSnippets.value = DEFAULT_SQL_SNIPPETS.map((s) => ({ ...s }));
}
function addTableColumnTemplateRow() {
const row = createEmptyTableColumnTemplateRow();
row.overrides.push({
id: uuid(),
databaseType: editTableColumnTemplateDatabaseType.value,
dataType: "",
});
editTableColumnTemplateRows.value.push(row);
}
function removeTableColumnTemplateRow(id: string) {
const row = editTableColumnTemplateRows.value.find((item) => item.id === id);
if (!row) return;
if (row.overrides.some((override) => override.databaseType === editTableColumnTemplateDatabaseType.value)) {
row.overrides = row.overrides.filter((override) => override.databaseType !== editTableColumnTemplateDatabaseType.value);
if (row.overrides.length > 0) return;
}
editTableColumnTemplateRows.value = editTableColumnTemplateRows.value.filter((item) => item.id !== id);
}
function moveTableColumnTemplateRow(sourceId: string, targetId: string, placement: "before" | "after") {
if (!sourceId || sourceId === targetId) return;
const rows = [...editTableColumnTemplateRows.value];
const sourceIndex = rows.findIndex((row) => row.id === sourceId);
const targetIndex = rows.findIndex((row) => row.id === targetId);
if (sourceIndex === -1 || targetIndex === -1) return;
const [source] = rows.splice(sourceIndex, 1);
if (!source) return;
const nextTargetIndex = rows.findIndex((row) => row.id === targetId);
const insertIndex = placement === "after" ? nextTargetIndex + 1 : nextTargetIndex;
rows.splice(nextTargetIndex === -1 ? rows.length : insertIndex, 0, source);
editTableColumnTemplateRows.value = rows;
}
function cleanupTableColumnTemplatePointerDrag() {
tableColumnTemplatePointerDragCleanup?.();
tableColumnTemplatePointerDragCleanup = null;
draggedTableColumnTemplateRowId.value = null;
document.body.style.cursor = "";
document.body.style.userSelect = "";
}
function startTableColumnTemplateRowDrag(id: string, event: PointerEvent) {
if (event.button !== 0) return;
event.preventDefault();
cleanupTableColumnTemplatePointerDrag();
draggedTableColumnTemplateRowId.value = id;
document.body.style.cursor = "grabbing";
document.body.style.userSelect = "none";
const onPointerMove = (moveEvent: PointerEvent) => {
const sourceId = draggedTableColumnTemplateRowId.value;
if (!sourceId) return;
const targetRow = document.elementFromPoint(moveEvent.clientX, moveEvent.clientY)?.closest<HTMLElement>("[data-table-column-template-row-id]");
const targetId = targetRow?.dataset.tableColumnTemplateRowId;
if (!targetRow || !targetId || targetId === sourceId) return;
const rect = targetRow.getBoundingClientRect();
moveTableColumnTemplateRow(sourceId, targetId, moveEvent.clientY > rect.top + rect.height / 2 ? "after" : "before");
};
const onPointerUp = () => cleanupTableColumnTemplatePointerDrag();
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onPointerUp, { once: true });
window.addEventListener("pointercancel", onPointerUp, { once: true });
tableColumnTemplatePointerDragCleanup = () => {
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", onPointerUp);
window.removeEventListener("pointercancel", onPointerUp);
};
}
function tableColumnTemplateTypeOptions(databaseType: DatabaseType): string[] {
return getDataTypeOptions(databaseType);
}
function tableColumnTemplateDataTypeForSelectedDatabase(row: TableColumnTemplateGridRow): string {
return row.overrides.find((override) => override.databaseType === editTableColumnTemplateDatabaseType.value)?.dataType ?? "";
}
function tableColumnTemplateBaseTypeForSelectedDatabase(row: TableColumnTemplateGridRow): string {
return splitDataType(tableColumnTemplateDataTypeForSelectedDatabase(row)).baseType;
}
function tableColumnTemplateLengthForSelectedDatabase(row: TableColumnTemplateGridRow): string {
return splitDataType(tableColumnTemplateDataTypeForSelectedDatabase(row)).params;
}
function setTableColumnTemplateDataTypeForSelectedDatabase(row: TableColumnTemplateGridRow, value: string) {
const dataType = value.trim();
const databaseType = editTableColumnTemplateDatabaseType.value;
const existing = row.overrides.find((override) => override.databaseType === databaseType);
if (!dataType) {
row.overrides = row.overrides.filter((override) => override.databaseType !== databaseType);
return;
}
if (existing) {
existing.dataType = dataType;
} else {
row.overrides.push({ id: uuid(), databaseType, dataType });
}
}
function setTableColumnTemplateBaseTypeForSelectedDatabase(row: TableColumnTemplateGridRow, value: string) {
const baseType = value.trim();
if (!baseType) {
setTableColumnTemplateDataTypeForSelectedDatabase(row, "");
return;
}
const databaseType = editTableColumnTemplateDatabaseType.value;
setTableColumnTemplateDataTypeForSelectedDatabase(row, combineDataTypeForDatabase(databaseType, baseType, getDefaultLengthForType(databaseType, baseType)));
}
function setTableColumnTemplateLengthForSelectedDatabase(row: TableColumnTemplateGridRow, value: string) {
const databaseType = editTableColumnTemplateDatabaseType.value;
const baseType = tableColumnTemplateBaseTypeForSelectedDatabase(row);
if (!baseType || isDataTypeLengthDisabled(databaseType, baseType)) return;
setTableColumnTemplateDataTypeForSelectedDatabase(row, combineDataTypeForDatabase(databaseType, baseType, value));
}
function isTableColumnTemplateLengthDisabled(row: TableColumnTemplateGridRow): boolean {
const baseType = tableColumnTemplateBaseTypeForSelectedDatabase(row);
return !baseType || isDataTypeLengthDisabled(editTableColumnTemplateDatabaseType.value, baseType);
}
function onExecuteModeChange(v: any) {
if (v === "all" || v === "current") editExecuteMode.value = v;
}
@ -1133,6 +1347,13 @@ const passwordMessage = ref("");
const passwordError = ref(false);
const changingPassword = ref(false);
async function scrollToInitialSettingsSection() {
await nextTick();
if (props.initialSection === "tableColumnTemplates") {
tableColumnTemplateSectionRef.value?.scrollIntoView({ block: "center", behavior: "smooth" });
}
}
watch(
() => props.open,
async (open) => {
@ -1154,11 +1375,19 @@ watch(
syncAiEditState();
if (!isWeb && activeSettingsTab.value === "mcp") void refreshMcpStatus();
if (!isWeb && activeSettingsTab.value === "ai" && aiIsCodexCli.value) void ensureCodexMcpStatus();
await scrollToInitialSettingsSection();
}
},
{ immediate: true },
);
watch(
() => props.initialSection,
() => {
if (props.open) void scrollToInitialSettingsSection();
},
);
watch([webdavEndpoint, webdavUsername], () => {
void refreshWebDavPasswordStatus();
});
@ -1192,6 +1421,7 @@ onUnmounted(() => {
window.clearInterval(webdavAutoUploadTimer);
webdavAutoUploadTimer = undefined;
}
cleanupTableColumnTemplatePointerDrag();
cleanupTruncationObservers();
});
@ -2324,6 +2554,98 @@ watch(
<Switch id="query-export-keyset-enabled" v-model="editQueryExportKeysetOptimizationEnabled" class="mt-0.5" />
</div>
</div>
<Separator />
<div class="space-y-3">
<div class="text-sm font-medium text-muted-foreground">{{ t("settings.tableStructureSection") }}</div>
<div ref="tableColumnTemplateSectionRef" class="space-y-2 rounded-md border bg-muted/20 px-3 py-2">
<div class="flex items-start justify-between gap-3">
<div class="space-y-1">
<Label>{{ t("settings.tableColumnTemplateFields") }}</Label>
<p class="text-xs text-muted-foreground">{{ t("settings.tableColumnTemplateFieldsDescription") }}</p>
</div>
<div class="flex items-center gap-2">
<Select v-model="editTableColumnTemplateDatabaseType">
<SelectTrigger class="h-8 w-44 px-2 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent class="max-h-72">
<SelectItem v-for="dbType in TABLE_COLUMN_TEMPLATE_DATABASE_TYPES" :key="dbType" :value="dbType">
{{ dbType }}
</SelectItem>
</SelectContent>
</Select>
<Button type="button" size="sm" variant="outline" @click="addTableColumnTemplateRow">
{{ t("settings.tableColumnTemplateAdd") }}
</Button>
</div>
</div>
<div class="overflow-x-auto rounded-md border bg-background">
<table class="w-full min-w-[900px] border-separate border-spacing-0 text-xs">
<thead class="bg-muted/50 text-muted-foreground">
<tr>
<th class="w-8 border-b px-2 py-1.5" />
<th class="border-b px-2 py-1.5 text-left font-medium">{{ t("settings.tableColumnTemplateColumn") }}</th>
<th class="border-b px-2 py-1.5 text-left font-medium">{{ t("settings.tableColumnTemplateType") }}</th>
<th class="border-b px-2 py-1.5 text-left font-medium">{{ t("settings.tableColumnTemplateLength") }}</th>
<th class="border-b px-2 py-1.5 text-left font-medium">{{ t("settings.tableColumnTemplateDefault") }}</th>
<th class="border-b px-2 py-1.5 text-left font-medium">{{ t("settings.tableColumnTemplateRequired") }}</th>
<th class="border-b px-2 py-1.5 text-left font-medium">{{ t("settings.tableColumnTemplateComment") }}</th>
<th class="w-10 border-b px-2 py-1.5" />
</tr>
</thead>
<tbody>
<tr v-for="row in visibleTableColumnTemplateRows" :key="row.id" :data-table-column-template-row-id="row.id" :class="draggedTableColumnTemplateRowId === row.id ? 'opacity-60' : ''">
<td class="border-b px-2 py-1.5 align-middle">
<button
type="button"
class="flex h-7 w-6 cursor-grab touch-none items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground active:cursor-grabbing"
:aria-label="t('settings.tableColumnTemplateDragHandle')"
@pointerdown="startTableColumnTemplateRowDrag(row.id, $event)"
>
<GripVertical class="h-3.5 w-3.5" />
</button>
</td>
<td class="border-b px-2 py-1.5">
<Input v-model="row.name" class="h-7 px-2 text-xs" />
</td>
<td class="border-b px-2 py-1.5">
<SearchableSelect
:model-value="tableColumnTemplateBaseTypeForSelectedDatabase(row)"
:options="tableColumnTemplateTypeOptions(editTableColumnTemplateDatabaseType)"
:placeholder="t('settings.tableColumnTemplateNoPresetType')"
:search-placeholder="t('structureEditor.typePlaceholder')"
:empty-text="t('structureEditor.noMatchingType')"
:loading-text="t('common.loading')"
:allow-custom="true"
:trigger-class="['h-7 w-full px-2 font-mono text-xs']"
@update:model-value="setTableColumnTemplateBaseTypeForSelectedDatabase(row, $event)"
/>
</td>
<td class="border-b px-2 py-1.5">
<Input :model-value="tableColumnTemplateLengthForSelectedDatabase(row)" class="h-7 w-28 px-2 font-mono text-xs" :disabled="isTableColumnTemplateLengthDisabled(row)" @update:model-value="setTableColumnTemplateLengthForSelectedDatabase(row, String($event))" />
</td>
<td class="border-b px-2 py-1.5">
<Input v-model="row.defaultValue" class="h-7 px-2 font-mono text-xs" />
</td>
<td class="border-b px-2 py-1.5">
<Switch v-model="row.required" />
</td>
<td class="border-b px-2 py-1.5">
<Input v-model="row.comment" class="h-7 px-2 text-xs" />
</td>
<td class="border-b px-2 py-1.5 text-right">
<Button type="button" variant="ghost" size="icon" class="h-7 w-7" @click="removeTableColumnTemplateRow(row.id)">
<X class="h-3.5 w-3.5" />
</Button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</section>
<section v-else-if="activeSettingsTab === 'redis'" class="flex flex-col gap-5 py-2">

View File

@ -26,6 +26,7 @@ const props = defineProps<{
connectionPrefill?: ConnectionDeepLinkDraft | null;
showSettingsDialog: boolean;
settingsInitialTab?: string;
settingsInitialSection?: string;
appVersion?: string;
showDangerDialog: boolean;
dangerSql: string;
@ -107,7 +108,7 @@ watch(
@connect-failed="emit('connectFailed', $event)"
@open-driver-store="emit('openDriverStore')"
/>
<EditorSettingsDialog v-if="showSettingsDialog" :open="showSettingsDialog" :initial-tab="settingsInitialTab || 'editor'" :app-version="appVersion" @update:open="emit('update:showSettingsDialog', $event)" />
<EditorSettingsDialog v-if="showSettingsDialog" :open="showSettingsDialog" :initial-tab="settingsInitialTab || 'editor'" :initial-section="settingsInitialSection" :app-version="appVersion" @update:open="emit('update:showSettingsDialog', $event)" />
<DangerConfirmDialog
v-if="showDangerDialog"
:open="showDangerDialog"

View File

@ -132,6 +132,7 @@ const emit = defineEmits<{
objectSchemaChange: [schema: string | undefined];
structureEditorSaved: [commentChanged: boolean];
structureEditorClose: [];
openSettings: [initialTab?: string, initialSection?: string];
}>();
const { t } = useI18n();
@ -1106,6 +1107,7 @@ defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExe
:table-name="activeTab.structureTableName || ''"
@saved="(commentChanged) => emit('structureEditorSaved', commentChanged)"
@close="emit('structureEditorClose')"
@open-settings="(initialTab, initialSection) => emit('openSettings', initialTab, initialSection)"
/>
</template>

View File

@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { AlertTriangle, Check, ChevronDown, ChevronUp, Copy, Database, Info, KeyRound, Loader2, Maximize2, Plus, RefreshCw, Save, SlidersHorizontal, Trash2, X } from "@lucide/vue";
import { AlertTriangle, Check, ChevronDown, ChevronUp, Copy, Database, Info, KeyRound, Loader2, Maximize2, Plus, RefreshCw, Save, Settings, SlidersHorizontal, Trash2, X } from "@lucide/vue";
import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@ -22,6 +22,7 @@ import { type SqlHighlighter, createShikiSqlHighlighter } from "@/lib/sqlHighlig
import { copyToClipboard } from "@/lib/clipboard";
import { queryTimeoutSecsForConnection } from "@/lib/queryTimeout";
import { type EditableStructureColumn, type EditableStructureForeignKey, type EditableStructureIndex, type EditableStructureTrigger } from "@/lib/tableStructureEditorSql";
import { PRESET_FIELDS_TEMPLATE_ID, createTableColumnTemplateDrafts } from "@/lib/tableColumnTemplates";
import { getTableMetadataCapabilities } from "@/lib/tableMetadataCapabilities";
import { canAddTableStructureColumn, getTableStructureCapabilities } from "@/lib/tableStructureCapabilities";
import { connectionObjectTreeQuerySchema, tableStructureDatabaseTypeForConnection } from "@/lib/jdbcDialect";
@ -80,6 +81,7 @@ const props = defineProps<{
const emit = defineEmits<{
saved: [commentChanged: boolean];
close: [];
openSettings: [initialTab?: string, initialSection?: string];
}>();
const activeTab = ref("columns");
@ -822,6 +824,20 @@ async function addColumn() {
input?.select();
}
function applyColumnTemplate(templateId: string) {
if (!canAddColumn.value) return;
activeTab.value = "columns";
const templateColumns = createTableColumnTemplateDrafts({
templateId,
databaseType: databaseType.value,
columnNames: settingsStore.editorSettings.tableColumnTemplateFields,
existingColumnNames: columns.value.map((column) => column.name),
createId: uuid,
});
if (!templateColumns.length) return;
columns.value.push(...templateColumns);
}
function removeNewColumn(column: EditableStructureColumn) {
columns.value = columns.value.filter((item) => item.id !== column.id);
}
@ -1354,6 +1370,18 @@ watch(activeTab, (tab) => {
<Plus :class="structureIconClass" />
{{ t("structureEditor.addColumn") }}
</Button>
<Button v-if="isCreateMode && activeTab === 'columns'" size="sm" variant="outline" :class="structureToolbarButtonClass" :disabled="!canAddColumn" @click="applyColumnTemplate(PRESET_FIELDS_TEMPLATE_ID)">
<Copy :class="structureIconClass" />
{{ t("structureEditor.columnTemplates") }}
</Button>
<Tooltip v-if="isCreateMode && activeTab === 'columns'">
<TooltipTrigger as-child>
<Button size="sm" variant="ghost" :class="structureToolbarButtonClass" :disabled="!canAddColumn" :aria-label="t('structureEditor.configureColumnTemplates')" @click="emit('openSettings', 'data', 'tableColumnTemplates')">
<Settings :class="structureIconClass" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("structureEditor.configureColumnTemplates") }}</TooltipContent>
</Tooltip>
<Button v-if="activeTab === 'indexes'" size="sm" :class="structureToolbarButtonClass" :disabled="!structureCapabilities.createIndex || indexesLoading" @click="addIndex">
<Plus :class="structureIconClass" />
{{ t("structureEditor.addIndex") }}

View File

@ -1458,6 +1458,9 @@ export default {
triggers: "Triggers",
ddl: "DDL",
addColumn: "Add Column",
columnTemplates: "Insert Preset Fields",
configureColumnTemplates: "Configure Preset Fields",
presetFieldsTemplate: "Preset Field Set",
addIndex: "Add Index",
addForeignKey: "Add Foreign Key",
addTrigger: "Add Trigger",
@ -2407,6 +2410,7 @@ export default {
debugLogsDownload: "Download logs",
debugLogsDownloaded: "Downloaded",
debugLogsClear: "Clear logs",
tableStructureSection: "Table Structure",
dataGridDisplay: "Data grid display",
showColumnCommentsInHeader: "Show column comments under names",
showColumnCommentsInHeaderDescription: "Display table column comments directly below grid column names.",
@ -2418,6 +2422,18 @@ export default {
infiniteScrollDescription: "Automatically load the next page of data when scrolling to the bottom of the table.",
infiniteScrollMaxRows: "Infinite scroll max rows",
infiniteScrollMaxRowsDescription: "Maximum number of rows to load in infinite scroll mode (100050000).",
tableColumnTemplateFields: "New Table Preset Fields",
tableColumnTemplateFieldsDescription: "Choose a database type, then configure the preset field types used when creating new tables.",
tableColumnTemplateAdd: "Add Field",
tableColumnTemplateColumn: "Field",
tableColumnTemplateDatabase: "Database",
tableColumnTemplateType: "Type",
tableColumnTemplateLength: "Length",
tableColumnTemplateDefault: "Default",
tableColumnTemplateNoPresetType: "No preset type",
tableColumnTemplateRequired: "Required",
tableColumnTemplateComment: "Description",
tableColumnTemplateDragHandle: "Drag to reorder",
sidebarActivation: "Sidebar activation",
sidebarActivationSingle: "Single click",
sidebarActivationSingleDescription: "Open actionable sidebar items with one click.",

View File

@ -1214,6 +1214,9 @@ export default {
triggers: "Disparadores",
ddl: "DDL",
addColumn: "Agregar columna",
columnTemplates: "Insertar campos predefinidos",
configureColumnTemplates: "Configurar campos predefinidos",
presetFieldsTemplate: "Conjunto de campos predefinidos",
addIndex: "Agregar índice",
columnName: "Columna",
dataType: "Tipo",
@ -2020,6 +2023,7 @@ export default {
debugLogsDownload: "Descargar logs",
debugLogsDownloaded: "Descargado",
debugLogsClear: "Borrar logs",
tableStructureSection: "Estructura de tabla",
dataGridDisplay: "Visualización de la tabla",
showColumnCommentsInHeader: "Mostrar comentarios bajo los nombres",
showColumnCommentsInHeaderDescription: "Muestra los comentarios de columnas directamente debajo del nombre de la columna.",
@ -2031,6 +2035,18 @@ export default {
infiniteScrollDescription: "Carga automáticamente la siguiente página de datos al desplazarse hasta el final de la tabla.",
infiniteScrollMaxRows: "Máximo de filas en desplazamiento infinito",
infiniteScrollMaxRowsDescription: "Número máximo de filas a cargar en modo desplazamiento infinito (100050000).",
tableColumnTemplateFields: "Campos predefinidos para tablas nuevas",
tableColumnTemplateFieldsDescription: "Elige un tipo de base de datos y configura los tipos de campo predefinidos usados al crear tablas nuevas.",
tableColumnTemplateAdd: "Agregar campo",
tableColumnTemplateColumn: "Campo",
tableColumnTemplateDatabase: "Base de datos",
tableColumnTemplateType: "Tipo",
tableColumnTemplateLength: "Longitud",
tableColumnTemplateDefault: "Valor por defecto",
tableColumnTemplateNoPresetType: "Sin tipo predefinido",
tableColumnTemplateRequired: "Obligatorio",
tableColumnTemplateComment: "Descripción",
tableColumnTemplateDragHandle: "Arrastrar para reordenar",
sidebarActivation: "Activación de la barra lateral",
sidebarActivationSingle: "Un clic",
sidebarActivationSingleDescription: "Abrir elementos accionables de la barra lateral con un clic.",

View File

@ -1338,6 +1338,9 @@ export default {
triggers: "Trigger",
ddl: "DDL",
addColumn: "Aggiungi Colonna",
columnTemplates: "Inserisci campi predefiniti",
configureColumnTemplates: "Configura campi predefiniti",
presetFieldsTemplate: "Set di campi predefiniti",
addIndex: "Aggiungi Indice",
columnName: "Colonna",
dataType: "Tipo",
@ -2086,6 +2089,7 @@ export default {
debugLogsDownload: "Scarica log",
debugLogsDownloaded: "Scaricati",
debugLogsClear: "Cancella log",
tableStructureSection: "Struttura tabella",
dataGridDisplay: "Visualizzazione griglia dati",
showColumnCommentsInHeader: "Mostra i commenti delle colonne sotto i nomi",
showColumnCommentsInHeaderDescription: "Visualizza i commenti delle colonne della tabella direttamente sotto i nomi delle colonne nella griglia.",
@ -2097,6 +2101,18 @@ export default {
infiniteScrollDescription: "Carica automaticamente la pagina successiva dei dati quando scorri fino in fondo alla tabella.",
infiniteScrollMaxRows: "Righe max a scorrimento infinito",
infiniteScrollMaxRowsDescription: "Numero massimo di righe da caricare in modalità scorrimento infinito (100050000).",
tableColumnTemplateFields: "Campi predefiniti per nuove tabelle",
tableColumnTemplateFieldsDescription: "Scegli un tipo di database e configura i tipi dei campi predefiniti usati durante la creazione di nuove tabelle.",
tableColumnTemplateAdd: "Aggiungi campo",
tableColumnTemplateColumn: "Campo",
tableColumnTemplateDatabase: "Database",
tableColumnTemplateType: "Tipo",
tableColumnTemplateLength: "Lunghezza",
tableColumnTemplateDefault: "Valore predefinito",
tableColumnTemplateNoPresetType: "Nessun tipo preimpostato",
tableColumnTemplateRequired: "Obbligatorio",
tableColumnTemplateComment: "Descrizione",
tableColumnTemplateDragHandle: "Trascina per riordinare",
sidebarActivation: "Attivazione barra laterale",
sidebarActivationSingle: "Singolo clic",
sidebarActivationSingleDescription: "Apri gli elementi della barra laterale con un solo clic.",

View File

@ -1437,6 +1437,9 @@ export default {
triggers: "トリガー",
ddl: "DDL",
addColumn: "列を追加",
columnTemplates: "プリセット列を挿入",
configureColumnTemplates: "プリセット列を設定",
presetFieldsTemplate: "プリセット列セット",
addIndex: "インデックスを追加",
addForeignKey: "外部キーを追加",
addTrigger: "トリガーを追加",
@ -2324,6 +2327,7 @@ export default {
debugLogsDownload: "ログをダウンロード",
debugLogsDownloaded: "ダウンロードしました",
debugLogsClear: "ログをクリア",
tableStructureSection: "テーブル構造",
dataGridDisplay: "データグリッド表示",
showColumnCommentsInHeader: "列名の下にコメントを表示",
showColumnCommentsInHeaderDescription: "グリッド列名の直下にテーブル列コメントを表示します。",
@ -2331,6 +2335,18 @@ export default {
showColumnTypesInHeaderDescription: "グリッド列名の直下に各列のデータ型を表示します。",
compactColumnHeaderActions: "列ヘッダーツールをコンパクトに",
compactColumnHeaderActionsDescription: "フォーマッターとローカルフィルターツールをもっと見るメニューに移動し、列名を優先表示します。",
tableColumnTemplateFields: "新規テーブルのプリセット列",
tableColumnTemplateFieldsDescription: "データベース種別を選択し、新規テーブル作成時に使うプリセット列の型を設定します。",
tableColumnTemplateAdd: "列を追加",
tableColumnTemplateColumn: "列",
tableColumnTemplateDatabase: "データベース",
tableColumnTemplateType: "型",
tableColumnTemplateLength: "長さ",
tableColumnTemplateDefault: "デフォルト",
tableColumnTemplateNoPresetType: "プリセット型なし",
tableColumnTemplateRequired: "必須",
tableColumnTemplateComment: "説明",
tableColumnTemplateDragHandle: "ドラッグして並べ替え",
sidebarActivation: "サイドバーのアクティベーション",
sidebarActivationSingle: "シングルクリック",
sidebarActivationSingleDescription: "1クリックでサイドバー項目を開きます。",

View File

@ -1338,6 +1338,9 @@ export default {
triggers: "Triggers",
ddl: "DDL",
addColumn: "Adicionar coluna",
columnTemplates: "Inserir campos predefinidos",
configureColumnTemplates: "Configurar campos predefinidos",
presetFieldsTemplate: "Conjunto de campos predefinidos",
addIndex: "Adicionar índice",
addForeignKey: "Adicionar chave estrangeira",
addTrigger: "Adicionar trigger",
@ -2097,6 +2100,7 @@ export default {
debugLogsDownload: "Baixar logs",
debugLogsDownloaded: "Baixado",
debugLogsClear: "Limpar logs",
tableStructureSection: "Estrutura da tabela",
dataGridDisplay: "Exibição da grade de dados",
showColumnCommentsInHeader: "Mostrar comentários de coluna sob os nomes",
showColumnCommentsInHeaderDescription: "Exibir comentários de colunas da tabela diretamente abaixo dos nomes das colunas da grade.",
@ -2108,6 +2112,18 @@ export default {
infiniteScrollDescription: "Carregar automaticamente a próxima página de dados ao rolar até o final da tabela.",
infiniteScrollMaxRows: "Máximo de linhas em rolagem infinita",
infiniteScrollMaxRowsDescription: "Número máximo de linhas a carregar no modo de rolagem infinita (100050000).",
tableColumnTemplateFields: "Campos predefinidos para novas tabelas",
tableColumnTemplateFieldsDescription: "Escolha um tipo de banco de dados e configure os tipos dos campos predefinidos usados ao criar novas tabelas.",
tableColumnTemplateAdd: "Adicionar campo",
tableColumnTemplateColumn: "Campo",
tableColumnTemplateDatabase: "Banco de dados",
tableColumnTemplateType: "Tipo",
tableColumnTemplateLength: "Comprimento",
tableColumnTemplateDefault: "Padrão",
tableColumnTemplateNoPresetType: "Sem tipo predefinido",
tableColumnTemplateRequired: "Obrigatório",
tableColumnTemplateComment: "Descrição",
tableColumnTemplateDragHandle: "Arraste para reordenar",
sidebarActivation: "Ativação da barra lateral",
sidebarActivationSingle: "Clique único",
sidebarActivationSingleDescription: "Abrir itens acionáveis da barra lateral com um clique.",

View File

@ -1457,6 +1457,9 @@ export default {
triggers: "触发器",
ddl: "DDL",
addColumn: "新增字段",
columnTemplates: "插入预设字段",
configureColumnTemplates: "配置预设字段",
presetFieldsTemplate: "预设字段组合",
addIndex: "新增索引",
addForeignKey: "新增外键",
addTrigger: "新增触发器",
@ -2431,6 +2434,7 @@ export default {
debugLogsDownload: "下载日志",
debugLogsDownloaded: "已下载",
debugLogsClear: "清空日志",
tableStructureSection: "表结构",
dataGridDisplay: "数据表格显示",
showColumnCommentsInHeader: "在字段名下方显示注释",
showColumnCommentsInHeaderDescription: "把表字段注释直接显示在结果表头字段名下方。",
@ -2442,6 +2446,18 @@ export default {
infiniteScrollDescription: "滚动到表格底部时自动加载下一页数据,无需手动翻页。",
infiniteScrollMaxRows: "无限滚动最大行数",
infiniteScrollMaxRowsDescription: "无限滚动模式下最多加载的行数100050000。",
tableColumnTemplateFields: "新建表预设字段",
tableColumnTemplateFieldsDescription: "先选择数据库类型,再配置新建表时使用的预设字段类型。",
tableColumnTemplateAdd: "新增字段",
tableColumnTemplateColumn: "字段",
tableColumnTemplateDatabase: "数据库",
tableColumnTemplateType: "类型",
tableColumnTemplateLength: "长度",
tableColumnTemplateDefault: "默认值",
tableColumnTemplateNoPresetType: "不预设类型",
tableColumnTemplateRequired: "必填",
tableColumnTemplateComment: "描述",
tableColumnTemplateDragHandle: "拖动调整顺序",
sidebarActivation: "侧边栏打开方式",
sidebarActivationSingle: "单击打开",
sidebarActivationSingleDescription: "单击即可打开侧边栏中的可操作项目。",

View File

@ -1318,6 +1318,9 @@ export default {
triggers: "觸發器",
ddl: "DDL",
addColumn: "新增欄位",
columnTemplates: "插入預設欄位",
configureColumnTemplates: "設定預設欄位",
presetFieldsTemplate: "預設欄位組合",
addIndex: "新增索引",
addForeignKey: "新增外鍵",
addTrigger: "新增觸發器",
@ -2148,6 +2151,7 @@ export default {
debugLogsDownload: "下載日誌",
debugLogsDownloaded: "已下載",
debugLogsClear: "清空日誌",
tableStructureSection: "表結構",
dataGridDisplay: "資料表格顯示",
showColumnCommentsInHeader: "在欄位名稱下方顯示註解",
showColumnCommentsInHeaderDescription: "直接在資料表格欄位名稱下方顯示資料表欄位註解。",
@ -2159,6 +2163,18 @@ export default {
infiniteScrollDescription: "滾動到表格底部時自動載入下一頁資料,無需手動翻頁。",
infiniteScrollMaxRows: "無限滾動最大筆數",
infiniteScrollMaxRowsDescription: "無限滾動模式下最多載入的筆數100050000。",
tableColumnTemplateFields: "新建表預設欄位",
tableColumnTemplateFieldsDescription: "先選擇資料庫類型,再設定新建表時使用的預設欄位類型。",
tableColumnTemplateAdd: "新增欄位",
tableColumnTemplateColumn: "欄位",
tableColumnTemplateDatabase: "資料庫",
tableColumnTemplateType: "類型",
tableColumnTemplateLength: "長度",
tableColumnTemplateDefault: "預設值",
tableColumnTemplateNoPresetType: "不預設類型",
tableColumnTemplateRequired: "必填",
tableColumnTemplateComment: "描述",
tableColumnTemplateDragHandle: "拖曳調整順序",
sidebarActivation: "側邊欄開啟方式",
sidebarActivationSingle: "單擊開啟",
sidebarActivationSingleDescription: "單擊即可開啟側邊欄中的可操作項目。",

View File

@ -0,0 +1,130 @@
import type { DatabaseType } from "@/types/database";
import type { EditableStructureColumn } from "@/lib/tableStructureEditorSql";
import { manifestDatabaseTypes } from "@/lib/databaseDriverManifest";
import { getTableStructureCapabilities } from "@/lib/tableStructureCapabilities";
export interface TableColumnTemplate {
id: string;
labelKey: string;
columnNames: string[];
}
export interface TableColumnTemplateField {
name: string;
dataTypesByDatabase: Partial<Record<DatabaseType, string>>;
defaultValue?: string;
isNullable?: boolean;
comment?: string;
}
export const PRESET_FIELDS_TEMPLATE_ID = "preset-fields";
export const EMPTY_TABLE_COLUMN_TEMPLATE_DATA_TYPE = "<empty>";
export const TABLE_COLUMN_TEMPLATE_DATABASE_TYPES: DatabaseType[] = manifestDatabaseTypes().filter(isTableColumnTemplateDatabaseType);
export const DEFAULT_TABLE_COLUMN_TEMPLATE_FIELDS: string[] = [];
export function normalizeTableColumnTemplateFields(value: unknown): string[] {
if (!Array.isArray(value)) return [...DEFAULT_TABLE_COLUMN_TEMPLATE_FIELDS];
const fields: string[] = [];
const seen = new Set<string>();
for (const item of value) {
if (typeof item !== "string") continue;
const field = item.trim();
const name = field.split("|")[0]?.trim();
if (!field || !name || seen.has(name.toLowerCase())) continue;
seen.add(name.toLowerCase());
fields.push(field);
}
return fields;
}
export function parseTableColumnTemplateFields(value: unknown): TableColumnTemplateField[] {
return normalizeTableColumnTemplateFields(value).map(parseTableColumnTemplateField);
}
export function tableColumnTemplates(columnNames: readonly string[] = DEFAULT_TABLE_COLUMN_TEMPLATE_FIELDS): TableColumnTemplate[] {
const fields = parseTableColumnTemplateFields([...columnNames]);
return [
{
id: PRESET_FIELDS_TEMPLATE_ID,
labelKey: "structureEditor.presetFieldsTemplate",
columnNames: fields.map((field) => field.name),
},
];
}
export function createTableColumnTemplateDrafts(options: { templateId: string; databaseType?: DatabaseType; columnNames?: readonly string[]; existingColumnNames?: Iterable<string>; createId: () => string }): EditableStructureColumn[] {
if (options.templateId !== PRESET_FIELDS_TEMPLATE_ID) return [];
const existingNames = new Set([...(options.existingColumnNames ?? [])].map((name) => name.toLowerCase()));
return presetFieldColumns(options.databaseType, parseTableColumnTemplateFields([...(options.columnNames ?? DEFAULT_TABLE_COLUMN_TEMPLATE_FIELDS)]), options.createId).filter((column) => !existingNames.has(column.name.toLowerCase()));
}
function presetFieldColumns(databaseType: DatabaseType | undefined, fields: readonly TableColumnTemplateField[], createId: () => string): EditableStructureColumn[] {
return fields
.filter((field) => isTableColumnTemplateFieldApplicable(field, databaseType))
.map((field) => {
return templateColumn(createId, field.name, configuredFieldDataType(field, databaseType) ?? "", field.isNullable ?? false, field.defaultValue ?? "", field.comment ?? "");
});
}
function templateColumn(createId: () => string, name: string, dataType: string, isNullable: boolean, defaultValue = "", comment = ""): EditableStructureColumn {
return {
id: `new:${createId()}`,
name,
dataType,
isNullable,
defaultValue,
comment,
isPrimaryKey: false,
extra: {},
markedForDrop: false,
};
}
function parseTableColumnTemplateField(value: string): TableColumnTemplateField {
const [rawName = "", ...rawParts] = value.split("|").map((part) => part.trim());
const field: TableColumnTemplateField = { name: rawName, dataTypesByDatabase: {} };
for (const part of rawParts) {
const separator = part.indexOf(":");
if (separator <= 0) continue;
const key = part.slice(0, separator).trim().toLowerCase();
const dataType = part.slice(separator + 1).trim();
if (!dataType) continue;
if (key === "nullable") {
field.isNullable = parseBooleanConfigValue(dataType);
} else if (key === "required") {
const required = parseBooleanConfigValue(dataType);
field.isNullable = required === undefined ? undefined : !required;
} else if (key === "default" || key === "defaultvalue" || key === "default_value") {
field.defaultValue = dataType;
} else if (key === "comment" || key === "description") {
field.comment = dataType;
} else if (isDatabaseTypeKey(key)) {
field.dataTypesByDatabase[key] = dataType;
}
}
return field;
}
function parseBooleanConfigValue(value: string): boolean | undefined {
const normalized = value.trim().toLowerCase();
if (normalized === "true" || normalized === "yes" || normalized === "1") return true;
if (normalized === "false" || normalized === "no" || normalized === "0") return false;
return undefined;
}
function configuredFieldDataType(field: TableColumnTemplateField, databaseType: DatabaseType | undefined): string | undefined {
const configuredDataType = databaseType ? field.dataTypesByDatabase[databaseType] : undefined;
return configuredDataType && configuredDataType !== EMPTY_TABLE_COLUMN_TEMPLATE_DATA_TYPE ? configuredDataType : undefined;
}
function isTableColumnTemplateFieldApplicable(field: TableColumnTemplateField, databaseType: DatabaseType | undefined): boolean {
return !!databaseType && Object.prototype.hasOwnProperty.call(field.dataTypesByDatabase, databaseType);
}
function isTableColumnTemplateDatabaseType(databaseType: DatabaseType): boolean {
return databaseType !== "manticoresearch" && getTableStructureCapabilities(databaseType).createTable;
}
function isDatabaseTypeKey(value: string): value is DatabaseType {
return TABLE_COLUMN_TEMPLATE_DATABASE_TYPES.includes(value as DatabaseType);
}

View File

@ -10,6 +10,7 @@ import type { SidebarActivation } from "@/lib/treeNodeClick";
import type { SqlSnippet } from "@/types/database";
import { DEFAULT_SQL_SNIPPETS } from "@/lib/sqlCompletion";
import { setDebugLoggingEnabled } from "@/lib/debugLog";
import { DEFAULT_TABLE_COLUMN_TEMPLATE_FIELDS, normalizeTableColumnTemplateFields } from "@/lib/tableColumnTemplates";
export type AiProvider = "claude" | "openai" | "gemini" | "deepseek" | "qwen" | "ollama" | "openai-compatible" | "codex-cli" | "custom";
export type AiApiStyle = "completions" | "responses";
@ -340,6 +341,7 @@ export interface EditorSettings {
columnFormatters: Record<string, ColumnFormatterConfig>;
customColumnFormatters: Record<string, CustomColumnFormatterConfig>;
snippets: SqlSnippet[];
tableColumnTemplateFields: string[];
exportBatchSize: number;
exportRowLimitEnabled: boolean;
exportRowLimit: number;
@ -443,6 +445,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = {
columnFormatters: {},
customColumnFormatters: {},
snippets: DEFAULT_SQL_SNIPPETS,
tableColumnTemplateFields: [...DEFAULT_TABLE_COLUMN_TEMPLATE_FIELDS],
exportBatchSize: 2000,
exportRowLimitEnabled: true,
exportRowLimit: 100000,
@ -613,6 +616,7 @@ export function normalizeEditorSettings(settings: Partial<EditorSettings>, exist
columnFormatters: normalizeColumnFormatters(settings.columnFormatters),
customColumnFormatters: normalizeCustomColumnFormatters(settings.customColumnFormatters),
snippets: normalizeSqlSnippets(settings.snippets, existing?.snippets),
tableColumnTemplateFields: normalizeTableColumnTemplateFields(settings.tableColumnTemplateFields),
exportBatchSize: typeof settings.exportBatchSize === "number" && settings.exportBatchSize >= 100 && settings.exportBatchSize <= 100000 ? Math.round(settings.exportBatchSize) : DEFAULT_EDITOR_SETTINGS.exportBatchSize,
exportRowLimitEnabled: typeof settings.exportRowLimitEnabled === "boolean" ? settings.exportRowLimitEnabled : DEFAULT_EDITOR_SETTINGS.exportRowLimitEnabled,
exportRowLimit: typeof settings.exportRowLimit === "number" && settings.exportRowLimit >= 100 && settings.exportRowLimit <= 2147483647 ? Math.round(settings.exportRowLimit) : DEFAULT_EDITOR_SETTINGS.exportRowLimit,
@ -784,6 +788,7 @@ export const useSettingsStore = defineStore("settings", () => {
if (partial.columnFormatters !== undefined) editorSettings.value.columnFormatters = partial.columnFormatters;
if (partial.customColumnFormatters !== undefined) editorSettings.value.customColumnFormatters = partial.customColumnFormatters;
if (partial.snippets !== undefined) editorSettings.value.snippets = normalizeSqlSnippets(partial.snippets);
if (partial.tableColumnTemplateFields !== undefined) editorSettings.value.tableColumnTemplateFields = normalizeTableColumnTemplateFields(partial.tableColumnTemplateFields);
if (partial.exportBatchSize !== undefined) editorSettings.value.exportBatchSize = Math.min(100000, Math.max(100, Math.round(partial.exportBatchSize)));
if (partial.exportRowLimitEnabled !== undefined) editorSettings.value.exportRowLimitEnabled = partial.exportRowLimitEnabled;
if (partial.exportRowLimit !== undefined) editorSettings.value.exportRowLimit = Math.min(2147483647, Math.max(100, Math.round(partial.exportRowLimit)));

View File

@ -2,6 +2,7 @@ import { test } from "vitest";
import assert from "node:assert/strict";
import { createPinia, setActivePinia } from "pinia";
import { DEFAULT_SQL_FORMATTER_SETTINGS } from "../../apps/desktop/src/lib/sqlFormatterConfig.ts";
import { DEFAULT_TABLE_COLUMN_TEMPLATE_FIELDS } from "../../apps/desktop/src/lib/tableColumnTemplates.ts";
import { AI_PROVIDER_PRESETS, DEFAULT_EDITOR_SETTINGS, normalizeAiConfig, normalizeEditorSettings, useSettingsStore } from "../../apps/desktop/src/stores/settingsStore.ts";
const OLD_FONT_SIZE_KEY = "dbx-query-editor-font-size";
@ -206,6 +207,22 @@ test("normalizes table structure editor density", () => {
assert.equal(normalizeEditorSettings({ structureEditorDensity: "invalid" as any }).structureEditorDensity, "compact");
});
test("normalizes table column template fields", () => {
assert.deepEqual(DEFAULT_EDITOR_SETTINGS.tableColumnTemplateFields, DEFAULT_TABLE_COLUMN_TEMPLATE_FIELDS);
assert.deepEqual(DEFAULT_EDITOR_SETTINGS.tableColumnTemplateFields, []);
assert.deepEqual(normalizeEditorSettings({}).tableColumnTemplateFields, DEFAULT_TABLE_COLUMN_TEMPLATE_FIELDS);
const normalizedTemplateFields = normalizeEditorSettings({ tableColumnTemplateFields: [" tenant_id | mysql:bigint ", "request_id | mysql:varchar(64)", "TENANT_ID | mysql:bigint", ""] } as any).tableColumnTemplateFields;
assert.equal(
normalizedTemplateFields.find((field) => field.startsWith("tenant_id")),
"tenant_id | mysql:bigint",
);
assert.equal(
normalizedTemplateFields.find((field) => field.startsWith("request_id")),
"request_id | mysql:varchar(64)",
);
assert.deepEqual(normalizeEditorSettings({ tableColumnTemplateFields: [] } as any).tableColumnTemplateFields, DEFAULT_TABLE_COLUMN_TEMPLATE_FIELDS);
});
test("normalizes grid drawer widths", () => {
assert.equal(DEFAULT_EDITOR_SETTINGS.tableInfoDrawerWidth, 320);
assert.equal(DEFAULT_EDITOR_SETTINGS.cellDetailDrawerWidth, 380);

View File

@ -0,0 +1,116 @@
import { strict as assert } from "node:assert";
import { test } from "vitest";
import { createTableColumnTemplateDrafts, DEFAULT_TABLE_COLUMN_TEMPLATE_FIELDS, normalizeTableColumnTemplateFields, parseTableColumnTemplateFields, PRESET_FIELDS_TEMPLATE_ID, tableColumnTemplates, TABLE_COLUMN_TEMPLATE_DATABASE_TYPES } from "../../apps/desktop/src/lib/tableColumnTemplates.ts";
const sixCustomFields = [
"tenant_id | mysql:bigint | postgres:uuid | default:0 | comment:Tenant",
"request_id | mysql:varchar(64) | postgres:varchar(64)",
"created_time | mysql:datetime | postgres:timestamp | default:CURRENT_TIMESTAMP",
"modified_time | mysql:datetime | postgres:timestamp",
"creator_id | mysql:bigint | postgres:bigint",
"modifier_id | mysql:bigint | postgres:bigint | required:false",
];
test("has no built-in preset field names by default", () => {
assert.deepEqual(DEFAULT_TABLE_COLUMN_TEMPLATE_FIELDS, []);
assert.deepEqual(normalizeTableColumnTemplateFields(undefined), []);
assert.deepEqual(normalizeTableColumnTemplateFields([]), []);
assert.deepEqual(tableColumnTemplates(), [
{
id: PRESET_FIELDS_TEMPLATE_ID,
labelKey: "structureEditor.presetFieldsTemplate",
columnNames: [],
},
]);
});
test("builds no preset field drafts until users configure fields", () => {
const columns = createTableColumnTemplateDrafts({
templateId: PRESET_FIELDS_TEMPLATE_ID,
databaseType: "postgres",
createId: () => "id",
});
assert.deepEqual(columns, []);
});
test("builds configured preset field drafts", () => {
let id = 0;
const columns = createTableColumnTemplateDrafts({
templateId: PRESET_FIELDS_TEMPLATE_ID,
databaseType: "mysql",
columnNames: sixCustomFields,
createId: () => String(++id),
});
assert.deepEqual(
columns.map((column) => ({
id: column.id,
name: column.name,
dataType: column.dataType,
isNullable: column.isNullable,
defaultValue: column.defaultValue,
comment: column.comment,
})),
[
{ id: "new:1", name: "tenant_id", dataType: "bigint", isNullable: false, defaultValue: "0", comment: "Tenant" },
{ id: "new:2", name: "request_id", dataType: "varchar(64)", isNullable: false, defaultValue: "", comment: "" },
{ id: "new:3", name: "created_time", dataType: "datetime", isNullable: false, defaultValue: "CURRENT_TIMESTAMP", comment: "" },
{ id: "new:4", name: "modified_time", dataType: "datetime", isNullable: false, defaultValue: "", comment: "" },
{ id: "new:5", name: "creator_id", dataType: "bigint", isNullable: false, defaultValue: "", comment: "" },
{ id: "new:6", name: "modifier_id", dataType: "bigint", isNullable: true, defaultValue: "", comment: "" },
],
);
});
test("filters configured fields by current database type", () => {
const columns = createTableColumnTemplateDrafts({
templateId: PRESET_FIELDS_TEMPLATE_ID,
databaseType: "postgres",
columnNames: ["mysql_only | mysql:bigint", "postgres_only | postgres:uuid", "common_name | mysql:<empty> | postgres:<empty>", "common_code | mysql:varchar(64) | postgres:varchar(32)"],
createId: () => "id",
});
assert.deepEqual(
columns.map((column) => ({ name: column.name, dataType: column.dataType })),
[
{ name: "postgres_only", dataType: "uuid" },
{ name: "common_name", dataType: "" },
{ name: "common_code", dataType: "varchar(32)" },
],
);
});
test("normalizes configured preset fields without adding built-in fields", () => {
const [tenantId, requestId] = normalizeTableColumnTemplateFields([" tenant_id | mysql:bigint | postgres:uuid | default:0 ", "tenant_id | mysql:int", "", "request_id | mysql:varchar(64)"]);
assert.equal(tenantId, "tenant_id | mysql:bigint | postgres:uuid | default:0");
assert.equal(requestId, "request_id | mysql:varchar(64)");
const parsed = parseTableColumnTemplateFields(["tenant_id | mysql:bigint | postgres:uuid | nullable:true | default:0 | comment:Tenant"]);
assert.deepEqual(parsed[0], { name: "tenant_id", dataTypesByDatabase: { mysql: "bigint", postgres: "uuid" }, defaultValue: "0", isNullable: true, comment: "Tenant" });
});
test("limits preset field database types to create-table capable SQL structures", () => {
assert.ok(TABLE_COLUMN_TEMPLATE_DATABASE_TYPES.includes("mysql"));
assert.ok(TABLE_COLUMN_TEMPLATE_DATABASE_TYPES.includes("postgres"));
assert.ok(TABLE_COLUMN_TEMPLATE_DATABASE_TYPES.includes("clickhouse"));
assert.ok(!TABLE_COLUMN_TEMPLATE_DATABASE_TYPES.includes("mongodb"));
assert.ok(!TABLE_COLUMN_TEMPLATE_DATABASE_TYPES.includes("redis"));
assert.ok(!TABLE_COLUMN_TEMPLATE_DATABASE_TYPES.includes("elasticsearch"));
assert.ok(!TABLE_COLUMN_TEMPLATE_DATABASE_TYPES.includes("manticoresearch"));
});
test("skips configured preset fields that already exist", () => {
const columns = createTableColumnTemplateDrafts({
templateId: PRESET_FIELDS_TEMPLATE_ID,
databaseType: "mysql",
columnNames: sixCustomFields,
existingColumnNames: ["tenant_id", "MODIFIER_ID"],
createId: () => "x",
});
assert.deepEqual(
columns.map((column) => column.name),
["request_id", "created_time", "modified_time", "creator_id"],
);
});