diff --git a/apps/desktop/src/components/document/DocumentBrowser.vue b/apps/desktop/src/components/document/DocumentBrowser.vue index 6870b35cd..0829ffb03 100644 --- a/apps/desktop/src/components/document/DocumentBrowser.vue +++ b/apps/desktop/src/components/document/DocumentBrowser.vue @@ -2,7 +2,7 @@ import { computed, ref, nextTick, watch, onMounted, onBeforeUnmount } from "vue"; import { uuid } from "@/lib/common/utils"; import { useI18n } from "vue-i18n"; -import { RefreshCw, Trash2, Plus, Save, ChevronDown, ChevronUp, ChevronLeft, ChevronRight, Table2, Braces, X, Search, Wrench, Filter } from "@lucide/vue"; +import { RefreshCw, Trash2, Plus, Save, ChevronDown, ChevronUp, ChevronLeft, ChevronRight, Table2, Braces, X, Search, Wrench, Filter, Columns3Cog, SquareDashed, Minus, Rows3, AlignLeft, AlignRight, EyeOff } from "@lucide/vue"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Input } from "@/components/ui/input"; @@ -13,6 +13,9 @@ import ErrorBanner from "@/components/ui/ErrorBanner.vue"; import DataGrid from "@/components/grid/DataGrid.vue"; import DataGridColumnLayoutPopover from "@/components/grid/DataGridColumnLayoutPopover.vue"; import DataGridCopyFormatControl from "@/components/grid/DataGridCopyFormatControl.vue"; +import DataGridFontFamilyControl from "@/components/grid/DataGridFontFamilyControl.vue"; +import LightTooltip from "@/components/ui/LightTooltip.vue"; +import { Switch } from "@/components/ui/switch"; import QueryLoadingState from "@/components/common/QueryLoadingState.vue"; import * as api from "@/lib/backend/api"; import { useConnectionStore } from "@/stores/connectionStore"; @@ -34,6 +37,7 @@ import { searchDocumentFieldPathTree, documentFilterModeNeedsValue, documentFilterModeOptions, + documentFilterValueTypeOptions, documentStoreProviderFor, elasticsearchBoolClauseOptions, elasticsearchFieldPathTreeFromFieldNames, @@ -45,6 +49,7 @@ import { type DocumentFieldPathNode, type DocumentFilterMode, type DocumentFilterRule, + type DocumentFilterValueType, type DocumentStoreKind, type ElasticsearchBoolClause, type ElasticsearchQueryType, @@ -63,12 +68,12 @@ import { import { applyDocumentStoreIdentityPlan, insertDocumentStoreDocument as insertDocumentStoreDocumentCore } from "@/lib/app/documentStoreSave"; import RedisJsonEditor from "@/components/redis/RedisJsonEditor.vue"; import { isLosslessJsonNumber, parseJsonPreservingLargeNumbers } from "@/lib/common/safeJsonFormat"; -import { buildMongoInsertDocument, buildMongoUpdateDocument, formatMongoShellLiteral, mongoDocumentDisplayValue, mongoDocumentIdForGrid, parseMongoDocumentInputValue, serializeMongoDocumentId, type MongoInputValue } from "@/lib/mongo/mongoDocumentValues"; +import { buildMongoInsertDocument, buildMongoUpdateDocument, formatMongoShellLiteral, mongoDocumentDisplayValue, mongoDocumentGridColumnTypes, mongoDocumentIdForGrid, parseMongoDocumentInputValue, serializeMongoDocumentId, type MongoInputValue } from "@/lib/mongo/mongoDocumentValues"; import { normalizeResultPageSize } from "@/lib/dataGrid/paginationPageSize"; import { findDocumentTextMatches, renderDocumentJsonHtml } from "@/lib/document/documentJsonSearch"; import { documentDataGridColumnLayoutScopeKey } from "@/lib/dataGrid/dataGridColumnLayoutStorage"; import { documentGridColumnVisibilityScopeKey, migrateDocumentGridColumnVisibilityToLayout } from "@/lib/document/documentGridColumnVisibilityStorage"; -import { useSettingsStore } from "@/stores/settingsStore"; +import { TABLE_FONT_SIZE_MAX, TABLE_FONT_SIZE_MIN, useSettingsStore } from "@/stores/settingsStore"; import JsonEditNode from "./JsonEditNode.vue"; import type { EditNode } from "@/types/editor"; import type { ColumnInfo, DatabaseType, QueryResult, QueryTab } from "@/types/database"; @@ -95,6 +100,7 @@ const documents = ref([]); const copyDocuments = ref([]); const mongoCopyDocumentsAvailable = ref(false); const lastGridColumns = ref([]); +const lastGridColumnTypes = ref([]); const total = ref(undefined); const totalIsExact = ref(true); const paginationTotal = ref(undefined); @@ -113,6 +119,10 @@ const isSavingDocument = ref(false); const error = ref(""); const editFields = ref([]); const showDeleteConfirm = ref(false); +const columnWidthDensity = computed(() => settingsStore.editorSettings.columnWidthDensity); +const dataGridRenderMode = computed(() => settingsStore.editorSettings.dataGridRenderMode); +const tableFontSize = computed(() => settingsStore.editorSettings.tableFontSize); +const numericColumnRightAlign = computed(() => settingsStore.editorSettings.numericColumnRightAlign ?? true); const viewMode = computed({ get: () => settingsStore.editorSettings.mongoViewMode, set: (value) => settingsStore.updateEditorSettings({ mongoViewMode: value }), @@ -136,6 +146,30 @@ function openDataGridExtractorConfiguration() { viewOptionsOpen.value = false; void nextTick(() => dataGridRef.value?.openExtractorConfiguration()); } + +function setColumnWidthDensity(value: "compact" | "standard" | "comfortable") { + settingsStore.updateEditorSettings({ columnWidthDensity: value }); +} + +function setDataGridRenderMode(value: "canvas" | "dom") { + settingsStore.updateEditorSettings({ dataGridRenderMode: value }); +} + +function setTableFontSize(value: number) { + settingsStore.updateEditorSettings({ tableFontSize: value }); +} + +function decreaseTableFontSize() { + setTableFontSize(tableFontSize.value - 1); +} + +function increaseTableFontSize() { + setTableFontSize(tableFontSize.value + 1); +} + +function setNumericColumnRightAlign(value: boolean) { + settingsStore.updateEditorSettings({ numericColumnRightAlign: value }); +} const tableSearchSplitContainerRef = ref(); const tableFindPaneWidth = ref(null); const isResizingTableSearchSplit = ref(false); @@ -223,11 +257,21 @@ const appliedDocumentFilter = ref | null>(null); const elasticsearchMappingFields = ref([]); const pendingDelete = ref(null); +const documentFilterComposingEditors = new Set(); +const documentFilterCompositionEndedAt = new Map(); +const DOCUMENT_FILTER_IME_COMPOSITION_END_GRACE_MS = 120; const selectedDoc = computed(() => { if (selectedIdx.value === null) return null; return documents.value[selectedIdx.value] ?? null; }); +const selectedDocumentIdLabel = computed(() => { + if (isNew.value) return "New"; + const id = selectedDoc.value?._id; + if (id === undefined || id === null) return ""; + return typeof id === "object" ? stringifyDocumentStoreValue(id, documentStoreProvider.value.kind) : String(id); +}); +const selectedDocumentIdWidth = computed(() => `${Math.min(Math.max(Array.from(selectedDocumentIdLabel.value).length + 2, 5), 52)}ch`); const documentSearchText = computed(() => editJson.value); const documentSearchMatches = computed(() => findDocumentTextMatches(documentSearchText.value, documentSearchQuery.value)); const documentSearchActiveIndex = computed(() => { @@ -266,6 +310,7 @@ const gridResult = computed(() => { if (!docs.length) { return { columns: lastGridColumns.value, + column_types: lastGridColumnTypes.value, rows: [], affected_rows: 0, execution_time_ms: 0, @@ -281,6 +326,7 @@ const gridResult = computed(() => { } } const columns = [...keySet]; + const columnTypes = documentStoreProvider.value.kind === "mongodb" ? mongoDocumentGridColumnTypes(docs, columns) : undefined; const rows = docs.map((doc) => columns.map((col) => { @@ -293,7 +339,7 @@ const gridResult = computed(() => { }), ); - return { columns, rows, mongo_documents: docs, mongo_copy_documents: copyDocuments.value, affected_rows: 0, execution_time_ms: 0, truncated: false }; + return { columns, column_types: columnTypes, rows, mongo_documents: docs, mongo_copy_documents: copyDocuments.value, affected_rows: 0, execution_time_ms: 0, truncated: false }; }); const expandedDocumentFilterFieldPaths = ref>(new Set()); const elasticsearchFieldTypes = computed(() => new Map(elasticsearchMappingFields.value.map((field) => [field.name, field.data_type]))); @@ -358,9 +404,52 @@ function ensureDocumentFilterRule() { } } -function addDocumentFilterRule() { +function appendDocumentFilterRule(openFieldSelect: boolean) { ensureDocumentFilterRule(); - documentFilterRules.value = [...documentFilterRules.value, createDocumentFilterRule()]; + const rule = createDocumentFilterRule(); + documentFilterRules.value = [...documentFilterRules.value, rule]; + if (openFieldSelect) setDocumentFilterFieldPopoverOpen(rule.id, true); +} + +function addDocumentFilterRule() { + appendDocumentFilterRule(false); +} + +function addDocumentFilterRuleFromKeyboard() { + appendDocumentFilterRule(true); +} + +function startDocumentFilterImeComposition(editorKey: string) { + documentFilterComposingEditors.add(editorKey); + documentFilterCompositionEndedAt.delete(editorKey); +} + +function endDocumentFilterImeComposition(editorKey: string) { + documentFilterComposingEditors.delete(editorKey); + documentFilterCompositionEndedAt.set(editorKey, Date.now()); +} + +function isDocumentFilterImeCompositionKey(event: KeyboardEvent, editorKey: string) { + const endedAt = documentFilterCompositionEndedAt.get(editorKey); + const justEnded = event.key === "Enter" && endedAt !== undefined && Date.now() - endedAt <= DOCUMENT_FILTER_IME_COMPOSITION_END_GRACE_MS; + if (justEnded || (endedAt !== undefined && event.key !== "Process")) documentFilterCompositionEndedAt.delete(editorKey); + return event.isComposing || event.key === "Process" || event.keyCode === 229 || documentFilterComposingEditors.has(editorKey) || justEnded; +} + +function handleDocumentFilterValueKeydown(event: KeyboardEvent, ruleId: string) { + const editorKey = `value:${ruleId}`; + if (isDocumentFilterImeCompositionKey(event, editorKey)) { + event.stopPropagation(); + return; + } + if (event.key !== "Enter") return; + event.preventDefault(); + if (!event.shiftKey) { + void applyDocumentStructuredFilters(); + return; + } + event.stopPropagation(); + if (!event.repeat) addDocumentFilterRuleFromKeyboard(); } function visibleDocumentFilterFieldRows(nodes: readonly DocumentFieldPathNode[], depth = 0): DocumentFilterFieldTreeRow[] { @@ -445,8 +534,9 @@ function updateDocumentFilterRule(ruleId: string, patch: Partial ({ - rule, - condition: buildDocumentFilterCondition(rule, { - kind: documentStoreProvider.value.kind, - sampleValue: documentFilterFieldByPath.value.get(rule.fieldName)?.sampleValue, - }), - })) - .filter((item): item is { rule: DocumentFilterRule; condition: Record } => !!item.condition); + let items: Array<{ rule: DocumentFilterRule; condition: Record }>; + try { + items = documentFilterRules.value + .map((rule) => ({ + rule, + condition: buildDocumentFilterCondition(rule, { + kind: documentStoreProvider.value.kind, + sampleValue: documentFilterFieldByPath.value.get(rule.fieldName)?.sampleValue, + }), + })) + .filter((item): item is { rule: DocumentFilterRule; condition: Record } => !!item.condition); + } catch (e) { + error.value = e instanceof Error ? e.message : String(e); + return; + } + error.value = ""; const structured = combineDocumentFilterConditions( items.map((item) => item.condition), items.map((item) => item.rule), @@ -934,6 +1031,7 @@ async function load(options: { page?: number } = {}) { } } lastGridColumns.value = [...keySet]; + lastGridColumnTypes.value = storeKind === "mongodb" ? mongoDocumentGridColumnTypes(nextDocuments, lastGridColumns.value) : []; } if (storeKind === "elasticsearch") { applyElasticsearchSearchTotal(result.total, result.total_is_exact !== false, filter); @@ -1421,6 +1519,10 @@ async function applyDeleteDoc(idx: number) { } function requestDeleteDoc(idx: number) { + if (!settingsStore.editorSettings.confirmDangerousSqlExecution) { + void applyDeleteDoc(idx); + return; + } pendingDelete.value = { kind: "document", index: idx }; showDeleteConfirm.value = true; } @@ -1461,6 +1563,16 @@ function highlightedJson(json: string): string { return renderDocumentJsonHtml(json, documentSearchOpen.value ? documentSearchQuery.value : "", documentSearchActiveIndex.value); } +function handleDocumentViewerDoubleClick(event: MouseEvent) { + const target = event.target; + if (!(target instanceof Element)) return; + const jsonViewer = target.closest(".json-viewer"); + if (jsonViewer && target !== jsonViewer) return; + const selection = window.getSelection(); + if (selection && !selection.isCollapsed && selection.toString()) return; + startEdit(); +} + function handleDocumentBrowserPointerDown(event: PointerEvent) { const target = event.target; documentViewerSearchActive.value = target instanceof Element && !!target.closest("[data-document-json-viewer], [data-document-search]"); @@ -1590,7 +1702,7 @@ defineExpose({ focusSearch });