diff --git a/apps/desktop/src/components/objects/ObjectBrowser.vue b/apps/desktop/src/components/objects/ObjectBrowser.vue index a25e21ed55..d4b9327af4 100644 --- a/apps/desktop/src/components/objects/ObjectBrowser.vue +++ b/apps/desktop/src/components/objects/ObjectBrowser.vue @@ -120,6 +120,7 @@ import { type ObjectBrowserSortKey, } from "@/lib/table/objectBrowserRows"; import { isSourceOnlyObjectBrowserRow, resolveRowClickAction, shouldDeferSingleClick, type ObjectBrowserRowAction } from "@/lib/table/objectBrowserRowAction"; +import { objectBrowserTableSelectionAnchor, objectBrowserTableSelectionRange } from "@/lib/table/objectBrowserSelection"; import { customTypeCapabilities, supportsTypeObjectSource } from "@/lib/database/databaseObjectCapabilities"; import { filterObjectBrowserTableColumns } from "@/lib/table/objectBrowserTableInfo"; import { createSidePanelRequestGuard } from "@/lib/table/sidePanelRequestGuard"; @@ -247,6 +248,7 @@ const duplicateTableName = ref(""); const showProcedureExecutionConfirm = ref(false); const procedureExecutionTarget = ref(null); const selectedTableIds = ref>(new Set()); +const tableSelectionAnchorId = ref(null); const expandedPartitionParentIds = ref>(new Set()); const showBatchDropConfirm = ref(false); const batchDropExecuting = ref(false); @@ -852,7 +854,27 @@ function executeRowAction(row: ObjectBrowserRow, action: ObjectBrowserRowAction) } } +function toggleTableSelectionWithAnchor(row: ObjectBrowserRow) { + toggleTableSelection(row); + tableSelectionAnchorId.value = row.id; +} + +function selectTableRangeFromAnchor(row: ObjectBrowserRow) { + const anchorId = objectBrowserTableSelectionAnchor(filteredRows.value, tableSelectionAnchorId.value, row.id); + tableSelectionAnchorId.value = anchorId; + setSelectedTableIds(new Set(objectBrowserTableSelectionRange(filteredRows.value, anchorId, row.id))); +} + function onRowClick(row: ObjectBrowserRow, event: MouseEvent) { + if (row.type === "TABLE" && event.shiftKey) { + selectTableRangeFromAnchor(row); + return; + } + if (row.type === "TABLE" && (event.metaKey || event.ctrlKey)) { + toggleTableSelectionWithAnchor(row); + return; + } + if (row.type === "TABLE") tableSelectionAnchorId.value = row.id; const activation = settingsStore.editorSettings.sidebarActivation; const { action, isDouble } = resolveRowClickAction(row, event.detail, activation, effectiveDatabaseType.value); // Double click: cancel any pending single-click and fire immediately diff --git a/apps/desktop/src/lib/__tests__/table/objectBrowserSelection.spec.ts b/apps/desktop/src/lib/__tests__/table/objectBrowserSelection.spec.ts new file mode 100644 index 0000000000..55bf79ecdc --- /dev/null +++ b/apps/desktop/src/lib/__tests__/table/objectBrowserSelection.spec.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { objectBrowserTableSelectionAnchor, objectBrowserTableSelectionRange } from "@/lib/table/objectBrowserSelection"; +import type { ObjectBrowserRow } from "@/lib/table/objectBrowserRows"; + +function row(type: ObjectBrowserRow["type"], name: string): ObjectBrowserRow { + return { id: `${type}-${name}`, name, displayName: name, type }; +} + +describe("objectBrowserTableSelectionRange", () => { + it("selects every table between anchor and current row, inclusive", () => { + const rows = [row("TABLE", "a"), row("TABLE", "b"), row("TABLE", "c"), row("TABLE", "d")]; + expect(objectBrowserTableSelectionRange(rows, "TABLE-a", "TABLE-c")).toEqual(["TABLE-a", "TABLE-b", "TABLE-c"]); + }); + + it("supports selecting upward when the current row precedes the anchor", () => { + const rows = [row("TABLE", "a"), row("TABLE", "b"), row("TABLE", "c"), row("TABLE", "d")]; + expect(objectBrowserTableSelectionRange(rows, "TABLE-d", "TABLE-b")).toEqual(["TABLE-b", "TABLE-c", "TABLE-d"]); + }); + + it("drops non-table rows inside the span, since only tables are selectable", () => { + const rows = [row("TABLE", "a"), row("VIEW", "v1"), row("PROCEDURE", "p1"), row("TABLE", "b")]; + expect(objectBrowserTableSelectionRange(rows, "TABLE-a", "TABLE-b")).toEqual(["TABLE-a", "TABLE-b"]); + }); + + it("returns just the current row when the anchor is not selectable and current is a table", () => { + const rows = [row("TABLE", "a"), row("TABLE", "b")]; + expect(objectBrowserTableSelectionRange(rows, "missing-anchor", "TABLE-b")).toEqual(["TABLE-b"]); + }); + + it("returns an empty range when the anchor is missing and current row is not a table", () => { + const rows = [row("VIEW", "v1"), row("TABLE", "b")]; + expect(objectBrowserTableSelectionRange(rows, "missing-anchor", "VIEW-v1")).toEqual([]); + }); + + it("collapses to a single row when anchor and current are the same table", () => { + const rows = [row("TABLE", "a"), row("TABLE", "b")]; + expect(objectBrowserTableSelectionRange(rows, "TABLE-a", "TABLE-a")).toEqual(["TABLE-a"]); + }); +}); + +describe("objectBrowserTableSelectionAnchor", () => { + it("keeps a visible table anchor", () => { + const rows = [row("TABLE", "a"), row("TABLE", "b")]; + expect(objectBrowserTableSelectionAnchor(rows, "TABLE-a", "TABLE-b")).toBe("TABLE-a"); + }); + + it("uses the current table when the anchor is missing or no longer visible", () => { + const rows = [row("TABLE", "a"), row("TABLE", "b")]; + expect(objectBrowserTableSelectionAnchor(rows, null, "TABLE-b")).toBe("TABLE-b"); + expect(objectBrowserTableSelectionAnchor(rows, "TABLE-filtered-out", "TABLE-b")).toBe("TABLE-b"); + }); +}); diff --git a/apps/desktop/src/lib/table/objectBrowserSelection.ts b/apps/desktop/src/lib/table/objectBrowserSelection.ts new file mode 100644 index 0000000000..90c5a6bc04 --- /dev/null +++ b/apps/desktop/src/lib/table/objectBrowserSelection.ts @@ -0,0 +1,25 @@ +import type { ObjectBrowserRow } from "@/lib/table/objectBrowserRows"; + +export function objectBrowserTableSelectionAnchor(rows: readonly ObjectBrowserRow[], anchorId: string | null, currentId: string): string { + if (anchorId && rows.some((row) => row.id === anchorId && row.type === "TABLE")) return anchorId; + return currentId; +} + +/** + * Compute the contiguous table-id range for a shift-click in the object + * browser, mirroring the sidebar tree's range-select behavior + * (sidebarTreeSelection.ts): the range spans every row between the anchor + * and the clicked row in visible order, but only TABLE rows are selectable + * so non-table rows inside the span are dropped from the result. + */ +export function objectBrowserTableSelectionRange(rows: readonly ObjectBrowserRow[], anchorId: string, currentId: string): string[] { + const anchorIndex = rows.findIndex((row) => row.id === anchorId); + const currentIndex = rows.findIndex((row) => row.id === currentId); + if (anchorIndex < 0 || currentIndex < 0) return rows.some((row) => row.id === currentId && row.type === "TABLE") ? [currentId] : []; + const start = Math.min(anchorIndex, currentIndex); + const end = Math.max(anchorIndex, currentIndex); + return rows + .slice(start, end + 1) + .filter((row) => row.type === "TABLE") + .map((row) => row.id); +}