From 4fed29fba2a25ee6a7475ab6e4253f2fb812c1f3 Mon Sep 17 00:00:00 2001 From: zipg Date: Thu, 27 Aug 2026 18:21:52 +0800 Subject: [PATCH] fix(structure): improve mysql auto-increment editing and field shortcuts --- ...leStructureEditor.charsetCollation.spec.ts | 58 ++++++- .../TableStructureEditor.primaryKey.spec.ts | 82 ++++++++- .../structure/TableStructureEditor.vue | 164 +++++++++++------- apps/desktop/src/i18n/locales/en.ts | 2 + apps/desktop/src/i18n/locales/es.ts | 2 + apps/desktop/src/i18n/locales/it.ts | 2 + apps/desktop/src/i18n/locales/ja.ts | 2 + apps/desktop/src/i18n/locales/ko.ts | 2 + apps/desktop/src/i18n/locales/pt-BR.ts | 2 + apps/desktop/src/i18n/locales/zh-CN.ts | 2 + apps/desktop/src/i18n/locales/zh-TW.ts | 2 + .../table/mysqlAutoIncrementCounter.spec.ts | 3 +- .../__tests__/mysqlAutoIncrementApi.spec.ts | 21 +++ apps/desktop/src/lib/backend/http.ts | 4 +- .../lib/table/mysqlAutoIncrementCounter.ts | 2 +- crates/dbx-web/src/main.rs | 1 + crates/dbx-web/src/routes/schema.rs | 12 ++ 17 files changed, 285 insertions(+), 78 deletions(-) create mode 100644 apps/desktop/src/lib/backend/__tests__/mysqlAutoIncrementApi.spec.ts diff --git a/apps/desktop/src/components/structure/TableStructureEditor.charsetCollation.spec.ts b/apps/desktop/src/components/structure/TableStructureEditor.charsetCollation.spec.ts index 9d1de3c2ca..a263443d9f 100644 --- a/apps/desktop/src/components/structure/TableStructureEditor.charsetCollation.spec.ts +++ b/apps/desktop/src/components/structure/TableStructureEditor.charsetCollation.spec.ts @@ -45,6 +45,7 @@ vi.mock("@lucide/vue", async () => { ListChevronsUpDown: Icon, Loader2: Icon, Maximize2: Icon, + Pencil: Icon, Plus: Icon, RefreshCw: Icon, Save: Icon, @@ -277,7 +278,7 @@ function draft(autoIncrement = false, counter?: { value?: string; originalValue? }; } -async function mountEditor(autoIncrement = false, counter?: { value?: string; originalValue?: string }) { +async function mountEditor(autoIncrement = false, counter?: { value?: string; originalValue?: string }, draftOverride?: ReturnType) { mocks.ensureConnected.mockResolvedValue(undefined); mocks.listDataTypes.mockResolvedValue([]); mocks.buildTableStructureChangeSql.mockResolvedValue({ statements: [], warnings: [] }); @@ -289,7 +290,7 @@ async function mountEditor(autoIncrement = false, counter?: { value?: string; or database: "test", schema: "test", tableName: "users", - draft: draft(autoIncrement, counter), + draft: draftOverride ?? draft(autoIncrement, counter), }); mountedApps.push(app); mountedEditor = app.mount(root) as unknown as { applyChanges: () => Promise }; @@ -357,6 +358,59 @@ describe("TableStructureEditor MySQL AUTO_INCREMENT counter", () => { const input = root.querySelector("[data-mysql-auto-increment-counter]"); expect(input?.value).toBe("10"); expect(input?.disabled).toBe(false); + expect(input?.classList.contains("structure-grid-control")).toBe(false); + expect(input?.classList.contains("font-mono")).toBe(true); + expect(input?.closest("td")).not.toBeNull(); + expect(root.querySelector("[data-mysql-auto-increment-editor-trigger]")?.getAttribute("title")).toContain("structureEditor.editMysqlAutoIncrementValue"); + }); + + it("loads an editable blank counter when auto-increment is checked before saving", async () => { + mocks.getMysqlTableAutoIncrement.mockResolvedValueOnce(null); + const root = await mountEditor(false); + expect(root.querySelector("[data-mysql-auto-increment-editor-trigger]")).toBeNull(); + + const autoIncrementLabel = Array.from(root.querySelectorAll("label")).find((label) => label.textContent?.includes("structureEditor.autoIncrement")); + const checkbox = autoIncrementLabel?.querySelector('input[type="checkbox"]'); + expect(checkbox).toBeTruthy(); + checkbox!.checked = true; + checkbox!.dispatchEvent(new Event("change", { bubbles: true })); + + await vi.waitFor(() => expect(mocks.getMysqlTableAutoIncrement).toHaveBeenCalledWith("structure-charset-test", "test", "users")); + const input = root.querySelector("[data-mysql-auto-increment-counter]"); + await vi.waitFor(() => expect(input?.disabled).toBe(false)); + expect(input?.value).toBe(""); + }); + + it("keeps a restored dirty blank-counter draft when the server has no counter", async () => { + mocks.getMysqlTableAutoIncrement.mockResolvedValueOnce(null); + const restoredDraft = draft(false, { value: "500", originalValue: "" }); + restoredDraft.columns[0].extra.autoIncrement = true; + const root = await mountEditor(false, undefined, restoredDraft); + + const input = root.querySelector("[data-mysql-auto-increment-counter]"); + await vi.waitFor(() => expect(input?.value).toBe("500")); + }); + + it("accepts unsigned bigint digits and rejects non-decimal input without rewriting it", async () => { + const root = await mountEditor(true); + const input = root.querySelector("[data-mysql-auto-increment-counter]")!; + await vi.waitFor(() => expect(input.value).toBe("10")); + expect(input.getAttribute("inputmode")).toBe("numeric"); + expect(input.getAttribute("pattern")).toBe("[0-9]*"); + + const maxUnsignedBigint = "18446744073709551615"; + input.value = maxUnsignedBigint; + input.dispatchEvent(new Event("input", { bubbles: true })); + await vi.waitFor(() => expect(mocks.buildMysqlAutoIncrementSql).toHaveBeenCalledWith(expect.objectContaining({ value: maxUnsignedBigint }))); + mocks.buildMysqlAutoIncrementSql.mockClear(); + + for (const invalidValue of ["abc", "-1", "+1", "1.5", "1e3"]) { + input.value = invalidValue; + input.dispatchEvent(new Event("input", { bubbles: true })); + expect(input.value).toBe(maxUnsignedBigint); + } + await new Promise((resolve) => setTimeout(resolve, 400)); + expect(mocks.buildMysqlAutoIncrementSql).not.toHaveBeenCalled(); }); it("refreshes a restored clean counter draft from the server", async () => { diff --git a/apps/desktop/src/components/structure/TableStructureEditor.primaryKey.spec.ts b/apps/desktop/src/components/structure/TableStructureEditor.primaryKey.spec.ts index 7958eba078..2714104d68 100644 --- a/apps/desktop/src/components/structure/TableStructureEditor.primaryKey.spec.ts +++ b/apps/desktop/src/components/structure/TableStructureEditor.primaryKey.spec.ts @@ -631,16 +631,25 @@ describe("TableStructureEditor data type options", () => { }); describe("TableStructureEditor action column", () => { - it("shows the field shortcut hints in the toolbar", async () => { + it("moves delayed shortcut hints onto the add, copy, and delete controls", async () => { const root = await mountEditor("dameng"); - expect(root.textContent).toContain("settings.shortcutsTab"); - expect(root.textContent).toContain("Shift+Enter"); - expect(root.textContent).toContain("⌘/Ctrl+D"); - expect(root.querySelector("[data-field-shortcut-hints]")?.className).toContain("flex-col"); - expect(root.querySelector("[data-field-shortcut-hints]")?.className).toContain("w-56"); - expect(root.querySelector("[data-field-shortcut-hints]")?.className).toContain("!bg-secondary"); - expect(root.querySelector("[data-field-shortcut-hints] kbd")?.className).toContain("text-primary"); + expect(root.textContent).not.toContain("settings.shortcutsTab"); + expect(root.querySelector("[data-field-shortcut-hints]")).toBeNull(); + + const addTooltip = root.querySelector("[data-add-column-shortcut-tooltip]"); + const copyTooltip = root.querySelector("[data-copy-column-shortcut-tooltip]"); + const deleteTooltip = root.querySelector("[data-delete-column-shortcut-tooltip]"); + expect(addTooltip?.getAttribute("delay-duration")).toBe("500"); + expect(copyTooltip?.getAttribute("delay-duration")).toBe("500"); + expect(deleteTooltip?.getAttribute("delay-duration")).toBe("500"); + expect(root.querySelector("[data-add-column-shortcut-content]")?.textContent).toBe("Shift+Enter"); + expect(root.querySelector("[data-copy-column-shortcut-content]")?.textContent).toBe("⌘/Ctrl+D"); + expect(root.querySelector("[data-delete-column-shortcut-content]")?.textContent?.trim()).toBe("⌘/Ctrl+Del"); + expect(root.querySelector("[data-add-column-shortcut-content]")?.textContent).not.toContain("structureEditor.addColumn"); + expect(root.querySelector("[data-copy-column-shortcut-content]")?.textContent).not.toContain("structureEditor.copyColumn"); + expect(copyTooltip?.querySelector("button")?.hasAttribute("title")).toBe(false); + expect(deleteTooltip?.querySelector("button")?.hasAttribute("title")).toBe(false); }); it("adds a field below the focused input on Shift+Enter", async () => { @@ -671,6 +680,63 @@ describe("TableStructureEditor action column", () => { expect(document.activeElement).toBe(copiedInput); }); + it.each([ + { label: "Ctrl+Delete", event: { key: "Delete", ctrlKey: true } }, + { label: "Cmd+Delete", event: { key: "Backspace", metaKey: true } }, + ])("removes a focused unsaved field on $label", async ({ event }) => { + const root = await mountEditor("dameng"); + buttonWithText(root, "structureEditor.addColumn").click(); + await vi.waitFor(() => expect(root.querySelector('[data-column-row-index="1"]')).not.toBeNull()); + const addedInput = root.querySelector('[data-column-row-index="1"] [data-column-name-input]'); + if (!addedInput) throw new Error("Missing added column name input"); + + addedInput.focus(); + addedInput.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, ...event })); + + await vi.waitFor(() => expect(root.querySelector('[data-column-row-index="1"]')).toBeNull()); + expect(root.querySelector('[data-column-row-index="0"]')).not.toBeNull(); + }); + + it("marks a focused persisted field for deletion on Mod+Delete", async () => { + const root = await mountEditor("dameng"); + const sourceInput = root.querySelector('[data-column-row-index="0"] [data-column-name-input]'); + if (!sourceInput) throw new Error("Missing source column name input"); + + sourceInput.focus(); + sourceInput.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Delete", ctrlKey: true })); + + await vi.waitFor(() => expect(root.querySelector('button[aria-label="structureEditor.restore"]')).not.toBeNull()); + }); + + it("keeps Ctrl+Backspace available for editing field text", async () => { + const root = await mountEditor("dameng"); + buttonWithText(root, "structureEditor.addColumn").click(); + await vi.waitFor(() => expect(root.querySelector('[data-column-row-index="1"]')).not.toBeNull()); + const addedInput = root.querySelector('[data-column-row-index="1"] [data-column-name-input]'); + if (!addedInput) throw new Error("Missing added column name input"); + + addedInput.focus(); + addedInput.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Backspace", ctrlKey: true })); + + await nextTick(); + expect(root.querySelector('[data-column-row-index="1"]')).not.toBeNull(); + }); + + it("keeps Cmd+Backspace available for editing non-empty field text", async () => { + const root = await mountEditor("dameng"); + buttonWithText(root, "structureEditor.addColumn").click(); + await vi.waitFor(() => expect(root.querySelector('[data-column-row-index="1"]')).not.toBeNull()); + const addedInput = root.querySelector('[data-column-row-index="1"] [data-column-name-input]'); + if (!addedInput) throw new Error("Missing added column name input"); + + addedInput.value = "name"; + addedInput.focus(); + addedInput.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Backspace", metaKey: true })); + + await nextTick(); + expect(root.querySelector('[data-column-row-index="1"]')).not.toBeNull(); + }); + it("widens the ordinal indicator for a two-digit primary-key row", async () => { const root = await mountEditor("dameng"); const addColumn = buttonWithText(root, "structureEditor.addColumn"); diff --git a/apps/desktop/src/components/structure/TableStructureEditor.vue b/apps/desktop/src/components/structure/TableStructureEditor.vue index f0347cfcfd..ccb835709e 100644 --- a/apps/desktop/src/components/structure/TableStructureEditor.vue +++ b/apps/desktop/src/components/structure/TableStructureEditor.vue @@ -7,7 +7,7 @@ import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; -import { AlertTriangle, Check, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Copy, Database, Info, Keyboard, KeyRound, ListChevronsUpDown, Loader2, Maximize2, Plus, RefreshCw, Save, Search, Settings, SlidersHorizontal, Trash2, UserRound, X } from "@lucide/vue"; +import { AlertTriangle, Check, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Copy, Database, Info, KeyRound, ListChevronsUpDown, Loader2, Maximize2, Pencil, Plus, RefreshCw, Save, Search, Settings, SlidersHorizontal, Trash2, UserRound, 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"; @@ -334,6 +334,7 @@ const structureDensityValues: StructureEditorDensity[] = ["compact", "standard", const STRUCTURE_COLUMNS_WIDTHS_STORAGE_KEY = "dbx-structure-editor-column-widths"; const STRUCTURE_INDEX_COLUMNS_WIDTHS_STORAGE_KEY = "dbx-structure-editor-index-column-widths"; const STRUCTURE_SQL_PREVIEW_COLLAPSED_STORAGE_KEY = "dbx-structure-editor-sql-preview-collapsed"; +const FIELD_SHORTCUT_TOOLTIP_DELAY_MS = 500; const STRUCTURE_COLUMN_WIDTH_COUNT = 12; const STRUCTURE_INDEX_COLUMN_WIDTH_COUNT = 9; const PERSISTED_STRUCTURE_INDEX_COLUMN_WIDTHS = new Set([0, 1, 6]); @@ -944,6 +945,24 @@ const supportsTableOwner = computed(() => !isCreateMode.value && databaseType.va const canEditMysqlAutoIncrement = computed(() => canEditMysqlAutoIncrementCounter(connection.value, isCreateMode.value, columns.value)); const canBuildMysqlAutoIncrement = computed(() => canEditMysqlAutoIncrement.value && !mysqlAutoIncrementLoading.value && !mysqlAutoIncrementLoadError.value && originalMysqlAutoIncrementValue.value !== undefined); const supportsMysqlEngine = computed(() => supportsMysqlTableEngine(connection.value)); +const hasPersistedMysqlAutoIncrementColumn = computed(() => columns.value.some((column) => !column.markedForDrop && (column.original?.extra ?? "").toLowerCase().includes("auto_increment"))); +function isMysqlAutoIncrementCounterColumn(column: EditableStructureColumn): boolean { + return canEditMysqlAutoIncrement.value && !column.markedForDrop && column.extra.autoIncrement === true; +} +function setMysqlAutoIncrement(column: EditableStructureColumn, checked: boolean) { + column.extra.autoIncrement = checked; + if (checked && originalMysqlAutoIncrementValue.value === undefined && !mysqlAutoIncrementLoading.value) { + void loadMysqlAutoIncrementCounter(true); + } +} +function onMysqlAutoIncrementInput(event: Event) { + const input = event.target as HTMLInputElement; + if (/^\d*$/.test(input.value)) { + mysqlAutoIncrementValue.value = input.value; + return; + } + input.value = mysqlAutoIncrementValue.value ?? ""; +} const tableOwnerOptions = computed(() => { const owner = tableOwner.value; if (!owner || tableOwnerRoles.value.includes(owner)) return tableOwnerRoles.value; @@ -1661,7 +1680,8 @@ async function loadMysqlAutoIncrementCounter(preserveDraft = false) { await store.ensureConnected(props.connectionId); const value = await api.getMysqlTableAutoIncrement(props.connectionId, props.database, props.tableName); if (requestId !== mysqlAutoIncrementLoadRequestId) return; - const draft = refreshMysqlAutoIncrementCounterDraft(value, { value: mysqlAutoIncrementValue.value, originalValue: originalMysqlAutoIncrementValue.value }, preserveDraft); + const server = value === null && !hasPersistedMysqlAutoIncrementColumn.value ? "" : value; + const draft = refreshMysqlAutoIncrementCounterDraft(server, { value: mysqlAutoIncrementValue.value, originalValue: originalMysqlAutoIncrementValue.value }, preserveDraft); originalMysqlAutoIncrementValue.value = draft.originalValue; mysqlAutoIncrementValue.value = draft.value; } catch (error: any) { @@ -3383,6 +3403,15 @@ function isShiftEnterShortcut(event: KeyboardEvent): boolean { return !event.isComposing && event.key === "Enter" && event.shiftKey && !event.altKey && !event.ctrlKey && !event.metaKey; } +function isPlainModDeleteShortcut(event: KeyboardEvent): boolean { + if (isPlainModShortcut(event, "delete")) return true; + if (!event.metaKey || event.ctrlKey || !isPlainModShortcut(event, "backspace")) return false; + // ⌘⌫ is macOS "delete to beginning of line" while editing text; only treat it as a + // field delete when the focused input is empty so normal text editing keeps working. + const target = event.target; + return target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement ? target.value === "" : true; +} + function onStructureEditorKeydown(event: KeyboardEvent) { const focusedColumn = activeTab.value === "columns" ? focusedEditableColumn(event.target) : undefined; if (focusedColumn && isShiftEnterShortcut(event) && canAddColumn.value) { @@ -3397,6 +3426,13 @@ function onStructureEditorKeydown(event: KeyboardEvent) { void copyColumn(focusedColumn); return; } + if (focusedColumn && isPlainModDeleteShortcut(event) && (!focusedColumn.original || canDropColumn(focusedColumn))) { + event.preventDefault(); + event.stopPropagation(); + if (focusedColumn.original) toggleDropColumn(focusedColumn); + else removeNewColumn(focusedColumn); + return; + } if (isPlainModShortcut(event, "f")) { event.preventDefault(); event.stopPropagation(); @@ -3750,26 +3786,6 @@ watch([activeTab, ddlLoading], ([tab, loading]) => { -
- - - - - - - {{ mysqlAutoIncrementLoadError }} - -
-
- - + - - -
- ↓ {{ t("structureEditor.addColumn") }} - Shift+Enter -
-
- ↓ {{ t("structureEditor.copyColumn") }} - ⌘/Ctrl+D -
-
+ Shift+Enter
- - - + + + + + ⌘/Ctrl+D + + + + + + + + {{ column.markedForDrop ? t("structureEditor.restore") : "⌘/Ctrl+Del" }} + + @@ -4208,9 +4210,43 @@ watch([activeTab, ddlLoading], ([tab, loading]) => {