From 0aca486c9e20439a51223bb2652bae77c685b1a2 Mon Sep 17 00:00:00 2001 From: "Kilian J. Halwachs" Date: Wed, 11 Mar 2026 10:25:20 +0100 Subject: [PATCH] refactor: table view * modularized code and introduced composables for: drag'n drop, column resizing, usage of document table * made table virtual to allow bigger datasets to render effectively * cached formatting of cells so it doesnt get executed on every render if the field is already known refers to #39 --- package.json | 1 + pnpm-lock.yaml | 19 +- .../documents/table/TableDocumentsView.vue | 295 +++++------------- src/components/tabs/views/TabViewQuery.vue | 13 +- src/composables/table/useColumnDragDrop.ts | 79 +++++ src/composables/table/useColumnSizing.ts | 66 ++++ src/composables/table/useDocumentTable.ts | 125 ++++++++ .../table/useFormattedValueCache.ts | 27 ++ src/utils/formatCellValues.ts | 32 ++ 9 files changed, 420 insertions(+), 237 deletions(-) create mode 100644 src/composables/table/useColumnDragDrop.ts create mode 100644 src/composables/table/useColumnSizing.ts create mode 100644 src/composables/table/useDocumentTable.ts create mode 100644 src/composables/table/useFormattedValueCache.ts create mode 100644 src/utils/formatCellValues.ts diff --git a/package.json b/package.json index cab4aec..a9387fc 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@codemirror/view": "^6.39.16", "@tailwindcss/vite": "^4.2.1", "@tanstack/vue-table": "^8.21.3", + "@tanstack/vue-virtual": "^3.13.21", "@tauri-apps/api": "^2.10.1", "@tauri-apps/plugin-log": "~2.8.0", "@tauri-apps/plugin-opener": "^2.5.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 39e44ae..6f93224 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: '@tanstack/vue-table': specifier: ^8.21.3 version: 8.21.3(vue@3.5.29(typescript@5.6.3)) + '@tanstack/vue-virtual': + specifier: ^3.13.21 + version: 3.13.21(vue@3.5.29(typescript@5.6.3)) '@tauri-apps/api': specifier: ^2.10.1 version: 2.10.1 @@ -596,8 +599,8 @@ packages: resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} engines: {node: '>=12'} - '@tanstack/virtual-core@3.13.19': - resolution: {integrity: sha512-/BMP7kNhzKOd7wnDeB8NrIRNLwkf5AhCYCvtfZV2GXWbBieFm/el0n6LOAXlTi6ZwHICSNnQcIxRCWHrLzDY+g==} + '@tanstack/virtual-core@3.13.21': + resolution: {integrity: sha512-ww+fmLHyCbPSf7JNbWZP3g7wl6SdNo3ah5Aiw+0e9FDErkVHLKprYUrwTm7dF646FtEkN/KkAKPYezxpmvOjxw==} '@tanstack/vue-table@8.21.3': resolution: {integrity: sha512-rusRyd77c5tDPloPskctMyPLFEQUeBzxdQ+2Eow4F7gDPlPOB1UnnhzfpdvqZ8ZyX2rRNGmqNnQWm87OI2OQPw==} @@ -605,8 +608,8 @@ packages: peerDependencies: vue: '>=3.2' - '@tanstack/vue-virtual@3.13.19': - resolution: {integrity: sha512-07Fp1TYuIziB4zIDA/moeDKHODePy3K1fN4c4VIAGnkxo1+uOvBJP7m54CoxKiQX6Q9a1dZnznrwOg9C86yvvA==} + '@tanstack/vue-virtual@3.13.21': + resolution: {integrity: sha512-zneUNdQTcUhoDl6+ek+/O4S9gSZRAc2q7VLscZ4WZnFfZcHc3M7OyVCfSDC3hGuwFqzfL8Cx5bZF6zbGCYwXmw==} peerDependencies: vue: ^2.7.0 || ^3.0.0 @@ -1645,16 +1648,16 @@ snapshots: '@tanstack/table-core@8.21.3': {} - '@tanstack/virtual-core@3.13.19': {} + '@tanstack/virtual-core@3.13.21': {} '@tanstack/vue-table@8.21.3(vue@3.5.29(typescript@5.6.3))': dependencies: '@tanstack/table-core': 8.21.3 vue: 3.5.29(typescript@5.6.3) - '@tanstack/vue-virtual@3.13.19(vue@3.5.29(typescript@5.6.3))': + '@tanstack/vue-virtual@3.13.21(vue@3.5.29(typescript@5.6.3))': dependencies: - '@tanstack/virtual-core': 3.13.19 + '@tanstack/virtual-core': 3.13.21 vue: 3.5.29(typescript@5.6.3) '@tauri-apps/api@2.10.1': {} @@ -2107,7 +2110,7 @@ snapshots: '@floating-ui/vue': 1.1.10(vue@3.5.29(typescript@5.6.3)) '@internationalized/date': 3.11.0 '@internationalized/number': 3.6.5 - '@tanstack/vue-virtual': 3.13.19(vue@3.5.29(typescript@5.6.3)) + '@tanstack/vue-virtual': 3.13.21(vue@3.5.29(typescript@5.6.3)) '@vueuse/core': 14.2.1(vue@3.5.29(typescript@5.6.3)) '@vueuse/shared': 14.2.1(vue@3.5.29(typescript@5.6.3)) aria-hidden: 1.2.6 diff --git a/src/components/documents/table/TableDocumentsView.vue b/src/components/documents/table/TableDocumentsView.vue index 7ec8eaa..602a98f 100644 --- a/src/components/documents/table/TableDocumentsView.vue +++ b/src/components/documents/table/TableDocumentsView.vue @@ -1,18 +1,15 @@ @@ -226,59 +61,75 @@ function needsTruncation(text: string, max = 120): boolean { No documents found for this query. -
- - - +
+
+ + - + diff --git a/src/components/tabs/views/TabViewQuery.vue b/src/components/tabs/views/TabViewQuery.vue index 30b8539..0bab2a5 100644 --- a/src/components/tabs/views/TabViewQuery.vue +++ b/src/components/tabs/views/TabViewQuery.vue @@ -11,7 +11,7 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { Label } from '@/components/ui/label'; -import { ScrollArea } from '@/components/ui/scroll-area'; +import ScrollArea from '@/components/ui/scroll-area/ScrollArea.vue'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { useDomain } from '@/domains'; import { FindDocumentsResult } from '@/domains/connections/models/findDocumentsResult'; @@ -184,12 +184,11 @@ onUnmounted(() => {
- - - +
+ + + - - - +
diff --git a/src/composables/table/useColumnDragDrop.ts b/src/composables/table/useColumnDragDrop.ts new file mode 100644 index 0000000..27809c3 --- /dev/null +++ b/src/composables/table/useColumnDragDrop.ts @@ -0,0 +1,79 @@ +import { ref, type Ref } from 'vue'; +import type { ColumnOrderState, Table } from '@tanstack/vue-table'; + +export function useColumnDragDrop(table: Table, columnOrder: Ref) { + const draggedColumnId = ref(null); + const dropTargetColumnId = ref(null); + const didDrag = ref(false); + + function onDragStart(event: DragEvent, columnId: string) { + draggedColumnId.value = columnId; + didDrag.value = false; + if (event.dataTransfer) { + event.dataTransfer.effectAllowed = 'move'; + event.dataTransfer.setData('text/plain', columnId); + + const ghost = document.createElement('div'); + ghost.textContent = columnId; + ghost.style.cssText = + 'position:fixed;left:-9999px;top:-9999px;padding:4px 10px;border-radius:4px;font-size:12px;font-weight:500;background:var(--secondary);color:var(--foreground);border:1px solid var(--border);white-space:nowrap;'; + document.body.appendChild(ghost); + event.dataTransfer.setDragImage(ghost, ghost.offsetWidth / 2, ghost.offsetHeight / 2); + requestAnimationFrame(() => document.body.removeChild(ghost)); + } + } + + function onDragOver(event: DragEvent, columnId: string) { + event.preventDefault(); + if (draggedColumnId.value && draggedColumnId.value !== columnId) { + dropTargetColumnId.value = columnId; + } + } + + function onDragLeave() { + dropTargetColumnId.value = null; + } + + function onDrop(event: DragEvent, targetColumnId: string) { + event.preventDefault(); + dropTargetColumnId.value = null; + + const sourceId = draggedColumnId.value; + if (!sourceId || sourceId === targetColumnId) return; + + didDrag.value = true; + const currentOrder = [...columnOrder.value]; + const sourceIndex = currentOrder.indexOf(sourceId); + const targetIndex = currentOrder.indexOf(targetColumnId); + if (sourceIndex === -1 || targetIndex === -1) return; + + currentOrder.splice(sourceIndex, 1); + currentOrder.splice(targetIndex, 0, sourceId); + + table.setColumnOrder(currentOrder); + } + + function onDragEnd() { + draggedColumnId.value = null; + dropTargetColumnId.value = null; + } + + function onHeaderClick(event: MouseEvent, handler?: (e: MouseEvent) => void) { + if (didDrag.value) { + didDrag.value = false; + return; + } + handler?.(event); + } + + return { + draggedColumnId, + dropTargetColumnId, + onDragStart, + onDragOver, + onDragLeave, + onDrop, + onDragEnd, + onHeaderClick, + }; +} diff --git a/src/composables/table/useColumnSizing.ts b/src/composables/table/useColumnSizing.ts new file mode 100644 index 0000000..5e56e2f --- /dev/null +++ b/src/composables/table/useColumnSizing.ts @@ -0,0 +1,66 @@ +import { type ColumnSizingState } from '@tanstack/vue-table'; +import { computed, ref, type Ref } from 'vue'; +import { type JsonDocument } from '@/components/documents/models/types'; + +export const MIN_COL_WIDTH = 60; +export const MAX_COL_WIDTH = 500; + +const MONO_CHAR_WIDTH = 8.4; +const HEADER_CHAR_WIDTH = 8; +const CELL_PADDING = 24; +const HEADER_ICONS_WIDTH = 44; +const COLUMN_SIZING_SAMPLE_SIZE = 500; + +function computeColumnWidths( + documents: JsonDocument[], + keys: string[], + getFormatted: (doc: JsonDocument, key: string) => string, +): Record { + const widths: Record = {}; + const sampleDocs = documents.length > COLUMN_SIZING_SAMPLE_SIZE + ? documents.slice(0, COLUMN_SIZING_SAMPLE_SIZE) + : documents; + + for (const key of keys) { + const headerWidth = key.length * HEADER_CHAR_WIDTH + CELL_PADDING + HEADER_ICONS_WIDTH; + + const lengths: number[] = []; + for (const doc of sampleDocs) { + lengths.push(getFormatted(doc, key).length); + } + + let dataWidth = 0; + if (lengths.length > 0) { + lengths.sort((a, b) => a - b); + const p90Index = Math.floor(lengths.length * 0.9); + const p90Length = lengths[Math.min(p90Index, lengths.length - 1)]; + dataWidth = p90Length * MONO_CHAR_WIDTH + CELL_PADDING; + } + + widths[key] = Math.max(MIN_COL_WIDTH, Math.min(MAX_COL_WIDTH, Math.max(headerWidth, dataWidth))); + } + return widths; +} + +export function useColumnSizing( + documents: Ref, + columnKeys: Ref, + getFormatted: (doc: JsonDocument, key: string) => string, +) { + const columnSizing = ref({}); + + const columnWidths = computed(() => computeColumnWidths(documents.value, columnKeys.value, getFormatted)); + + function onColumnSizingChange(updaterOrValue: ColumnSizingState | ((old: ColumnSizingState) => ColumnSizingState)) { + columnSizing.value = + typeof updaterOrValue === 'function' + ? updaterOrValue(columnSizing.value) + : updaterOrValue; + } + + return { + columnSizing, + columnWidths, + onColumnSizingChange, + }; +} diff --git a/src/composables/table/useDocumentTable.ts b/src/composables/table/useDocumentTable.ts new file mode 100644 index 0000000..6a2a72f --- /dev/null +++ b/src/composables/table/useDocumentTable.ts @@ -0,0 +1,125 @@ +import { + createColumnHelper, + getCoreRowModel, + getSortedRowModel, + useVueTable, + type ColumnOrderState, + type SortingState, +} from '@tanstack/vue-table'; +import { useLocalStorage } from '@vueuse/core'; +import { computed, ref, watch, type Ref } from 'vue'; +import { type JsonDocument } from '@/components/documents/models/types'; +import { useColumnSizing, MIN_COL_WIDTH, MAX_COL_WIDTH } from './useColumnSizing'; +import { useFormattedValueCache } from './useFormattedValueCache'; + +function mergeColumnOrder(saved: string[], current: string[]): string[] { + const currentSet = new Set(current); + const ordered = saved.filter((key) => currentSet.has(key)); + const orderedSet = new Set(ordered); + for (const key of current) { + if (!orderedSet.has(key)) ordered.push(key); + } + return ordered; +} + +export function useDocumentTable(documents: Ref, storageKey: Ref) { + const sorting = ref([]); + const columnOrder = ref([]); + + const savedColumnOrder = useLocalStorage( + computed(() => storageKey.value ? `monolens:columnOrder:${storageKey.value}` : 'monolens:columnOrder:__empty__'), + [], + ); + + const columnKeys = computed(() => { + const keySet = new Set(); + for (const doc of documents.value) { + for (const key of Object.keys(doc)) { + keySet.add(key); + } + } + const keys = [...keySet]; + keys.sort((a, b) => { + if (a === '_id') return -1; + if (b === '_id') return 1; + return a.localeCompare(b); + }); + return keys; + }); + + watch( + [columnKeys, savedColumnOrder], + ([keys, saved]) => { + if (saved.length > 0) { + columnOrder.value = mergeColumnOrder(saved, keys); + } else { + columnOrder.value = keys; + } + }, + { immediate: true }, + ); + + const columnHelper = createColumnHelper(); + + const { getFormatted } = useFormattedValueCache(documents); + const { columnSizing, columnWidths, onColumnSizingChange } = useColumnSizing(documents, columnKeys, getFormatted); + + const columns = computed(() => + columnKeys.value.map((key) => + columnHelper.accessor((row) => row[key], { + id: key, + header: key, + cell: (info) => getFormatted(documents.value[info.row.index], info.column.id), + sortingFn: 'auto', + size: columnWidths.value[key] ?? 150, + minSize: MIN_COL_WIDTH, + maxSize: MAX_COL_WIDTH, + }), + ), + ); + + const table = useVueTable({ + get data() { + return documents.value; + }, + get columns() { + return columns.value; + }, + state: { + get sorting() { + return sorting.value; + }, + get columnOrder() { + return columnOrder.value; + }, + get columnSizing() { + return columnSizing.value; + }, + }, + enableColumnResizing: true, + columnResizeMode: 'onChange', + onSortingChange: (updaterOrValue) => { + sorting.value = + typeof updaterOrValue === 'function' + ? updaterOrValue(sorting.value) + : updaterOrValue; + }, + onColumnOrderChange: (updaterOrValue) => { + const newOrder = + typeof updaterOrValue === 'function' + ? updaterOrValue(columnOrder.value) + : updaterOrValue; + columnOrder.value = newOrder; + savedColumnOrder.value = [...newOrder]; + }, + onColumnSizingChange, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + }); + + return { + table, + columnOrder, + getFormatted, + }; +} \ No newline at end of file diff --git a/src/composables/table/useFormattedValueCache.ts b/src/composables/table/useFormattedValueCache.ts new file mode 100644 index 0000000..f086743 --- /dev/null +++ b/src/composables/table/useFormattedValueCache.ts @@ -0,0 +1,27 @@ +import { watch, type Ref } from 'vue'; +import { type JsonDocument } from '@/components/documents/models/types'; +import { formatCellValue } from '@/utils/formatCellValues'; + +export function useFormattedValueCache(documents: Ref) { + let cache = new WeakMap>(); + + watch(documents, () => { + cache = new WeakMap(); + }); + + function getFormatted(doc: JsonDocument, key: string): string { + let docCache = cache.get(doc); + if (!docCache) { + docCache = new Map(); + cache.set(doc, docCache); + } + let value = docCache.get(key); + if (value === undefined) { + value = formatCellValue(doc[key]); + docCache.set(key, value); + } + return value; + } + + return { getFormatted }; +} diff --git a/src/utils/formatCellValues.ts b/src/utils/formatCellValues.ts new file mode 100644 index 0000000..2b441fd --- /dev/null +++ b/src/utils/formatCellValues.ts @@ -0,0 +1,32 @@ +export function formatCellValue(value: unknown): string { + if (value === undefined) return ''; + if (value === null) return 'null'; + if (typeof value === 'object' && value !== null) { + const obj = value as Record; + if ('$oid' in obj && typeof obj.$oid === 'string') return obj.$oid; // to get the actual _id value + // Extended JSON: Date + if ('$date' in obj) { + const d = obj.$date; + if (typeof d === 'string') return d; + if (typeof d === 'object' && d !== null && '$numberLong' in (d as Record)) { + return new Date(Number((d as Record).$numberLong)).toISOString(); + } + } + // Extended JSON: NumberLong / NumberInt / NumberDecimal / NumberDouble + if ('$numberLong' in obj && typeof obj.$numberLong === 'string') return obj.$numberLong; + if ('$numberInt' in obj && typeof obj.$numberInt === 'string') return obj.$numberInt; + if ('$numberDecimal' in obj && typeof obj.$numberDecimal === 'string') return obj.$numberDecimal; + if ('$numberDouble' in obj && typeof obj.$numberDouble === 'string') return obj.$numberDouble; + // Extended JSON: Timestamp + if ('$timestamp' in obj) return JSON.stringify(obj.$timestamp); + // Extended JSON: Binary + if ('$binary' in obj) return '[Binary]'; + // Extended JSON: Regex + if ('$regularExpression' in obj) { + const re = obj.$regularExpression as Record; + return `/${re.pattern ?? ''}/${re.options ?? ''}`; + } + return JSON.stringify(value); + } + return String(value); +} \ No newline at end of file
-
+
+
- - - - {{ truncate(formatCellValue(cell.getValue())) }} - - - {{ formatCellValue(cell.getValue()) }} - - - + - + {{ getFormatted(documents[rows[virtualRow.index].index], cell.column.id) }}