Files
dbx/apps/desktop/src/components/document/DocumentBrowser.vue
T
二丫讲梵 930aa48c20 feat(grid): 新增原始数据复制模式并设为默认
* ✨ feat(grid): 新增原始数据复制模式并设为默认

- 原样输出单元格内容,不对引号、制表符和换行进行 DSV 转义
- 支持原始数据复制后的网格粘贴还原
- 补充多语言复制选项文案及前后端提取器实现与测试

* ✨ feat(grid): 优化新增智能复制选项

- 单个单元格复制保留原始单元格文本,多单元格自动使用转义 TSV
- 默认复制设置由格式选择升级为复制行为选择,并支持预览
- 限制原始文本格式仅用于单单元格复制
- 更新多语言文案及相关测试
2026-08-05 23:58:01 +08:00

2171 lines
94 KiB
Vue

<script setup lang="ts">
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 { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
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 QueryLoadingState from "@/components/common/QueryLoadingState.vue";
import * as api from "@/lib/backend/api";
import { useConnectionStore } from "@/stores/connectionStore";
import { clampSearchSplitWidth } from "@/lib/dataGrid/dataGridSearchSplit";
import { documentViewerFontStyle } from "@/lib/document/documentViewerFontStyle";
import { clampDocumentPage, documentPageRequestLimit, resetElasticsearchDocumentTotals, resolveElasticsearchDocumentTotals } from "@/lib/document/elasticsearchDocumentTotals";
import { canGoNextDocumentPage, isSameDocumentQueryTotalCountRequest, resolveDocumentQueryTotals, type DocumentQueryTotalCountRequest } from "@/lib/document/documentQueryTotals";
import {
arrayObjectAncestorPathForDocumentField,
buildDocumentFilterCondition,
buildElasticsearchQueryFromRules,
combineDocumentFilterConditions,
currentDocumentFilterJson,
currentDocumentSortJson,
defaultDocumentFilterRule,
documentFieldPathOptionsFromDocuments,
documentFieldPathTreeFromDocuments,
flattenDocumentFieldPathTree,
searchDocumentFieldPathTree,
documentFilterModeNeedsValue,
documentFilterModeOptions,
documentStoreProviderFor,
elasticsearchBoolClauseOptions,
elasticsearchFieldPathTreeFromFieldNames,
elasticsearchQueryTypeNeedsValue,
elasticsearchQueryTypeOptions,
elasticsearchStructuredFilter,
formatDocumentQueryInput,
searchElasticsearchFieldPathTree,
type DocumentFieldPathNode,
type DocumentFilterMode,
type DocumentFilterRule,
type DocumentStoreKind,
type ElasticsearchBoolClause,
type ElasticsearchQueryType,
} from "@/lib/app/documentStoreProvider";
import {
isDocumentStoreIdentityField,
normalizeDocumentStoreRouting,
parseDocumentStoreInputValue,
parseDocumentStoreJsonDocument,
planDocumentStoreIdentityMigration,
resolveDocumentStoreWriteRouting,
serializeDocumentStoreId,
stringifyDocumentStoreValue,
documentStoreValueForGrid,
} from "@/lib/app/documentJsonValues";
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 { 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 JsonEditNode from "./JsonEditNode.vue";
import type { EditNode } from "@/types/editor";
import type { ColumnInfo, DatabaseType, QueryResult, QueryTab } from "@/types/database";
import type { CustomSaveHandler } from "@/composables/useDataGridEditor";
import { Splitpanes, Pane } from "splitpanes";
import "splitpanes/dist/splitpanes.css";
const { t } = useI18n();
const settingsStore = useSettingsStore();
const connectionStore = useConnectionStore();
const props = defineProps<{
connectionId: string;
database: string;
collection: string;
databaseType?: DatabaseType;
tableMeta?: NonNullable<QueryTab["tableMeta"]>;
}>();
type JsonRecord = Record<string, unknown>;
type ViewMode = "document" | "table";
const documents = ref<JsonRecord[]>([]);
const copyDocuments = ref<JsonRecord[]>([]);
const mongoCopyDocumentsAvailable = ref(false);
const lastGridColumns = ref<string[]>([]);
const total = ref<number | undefined>(undefined);
const totalIsExact = ref(true);
const paginationTotal = ref<number | undefined>(undefined);
const loading = ref(false);
const documentLoadExecutionId = ref("");
const documentLoadCancelling = ref(false);
const documentLoadingElapsedSeconds = ref("0.0");
const page = ref(0);
const pageSize = ref(normalizeResultPageSize(settingsStore.editorSettings.pageSize));
const selectedIdx = ref<number | null>(null);
const editJson = ref("");
const isEditing = ref(false);
const isNew = ref(false);
const documentEditMode = ref<"fields" | "json">("json");
const isSavingDocument = ref(false);
const error = ref("");
const editFields = ref<EditNode[]>([]);
const showDeleteConfirm = ref(false);
const viewMode = computed<ViewMode>({
get: () => settingsStore.editorSettings.mongoViewMode,
set: (value) => settingsStore.updateEditorSettings({ mongoViewMode: value }),
});
const filterInput = ref("");
const sortInput = ref("");
const filterInputRef = ref<HTMLTextAreaElement>();
const sortInputRef = ref<HTMLTextAreaElement>();
const dataGridRef = ref<InstanceType<typeof DataGrid>>();
const viewOptionsOpen = ref(false);
const mongoUpdateTarget = computed(() => (props.databaseType === "mongodb" && mongoCopyDocumentsAvailable.value ? { collection: props.collection, idColumn: "_id" as const } : undefined));
const documentViewerRef = ref<HTMLElement>();
const documentSearchInputRef = ref<HTMLInputElement>();
const documentSearchOpen = ref(false);
const documentSearchQuery = ref("");
const documentSearchMatchIndex = ref(0);
const documentSearchHasNavigated = ref(false);
const documentViewerSearchActive = ref(false);
function openDataGridExtractorConfiguration() {
viewOptionsOpen.value = false;
void nextTick(() => dataGridRef.value?.openExtractorConfiguration());
}
const tableSearchSplitContainerRef = ref<HTMLDivElement>();
const tableFindPaneWidth = ref<number | null>(null);
const isResizingTableSearchSplit = ref(false);
let tableSearchSplitStartX = 0;
let tableSearchSplitStartWidth = 0;
let elasticsearchCountKey: string | null = null;
let elasticsearchExactTotal: number | undefined;
let elasticsearchPaginationLowerBound: number | undefined;
let elasticsearchCountExecutionId = "";
let elasticsearchCountGeneration = 0;
type LoadedDocumentQueryTotalCountRequest = DocumentQueryTotalCountRequest & { storeKind: DocumentStoreKind };
let loadedDocumentQueryTotalCountRequest: LoadedDocumentQueryTotalCountRequest | undefined;
let documentRequestGeneration = 0;
const documentStoreProvider = computed(() => documentStoreProviderFor(props.databaseType));
const documentColumnLayoutScopeKey = computed(() =>
documentDataGridColumnLayoutScopeKey({
databaseType: props.databaseType ?? "mongodb",
connectionId: props.connectionId,
database: props.database,
collection: props.collection,
}),
);
const legacyDocumentColumnVisibilityScopeKey = computed(() =>
documentGridColumnVisibilityScopeKey({
databaseType: props.databaseType,
connectionId: props.connectionId,
database: props.database,
collection: props.collection,
}),
);
watch(
[legacyDocumentColumnVisibilityScopeKey, documentColumnLayoutScopeKey],
([legacyScopeKey, layoutScopeKey]) => {
migrateDocumentGridColumnVisibilityToLayout(legacyScopeKey, layoutScopeKey);
},
{ immediate: true },
);
const pageTotal = computed(() => paginationTotal.value);
const documentPageCount = computed(() => (pageTotal.value === undefined ? undefined : Math.max(1, Math.ceil(pageTotal.value / pageSize.value))));
const canGoNextPage = computed(() =>
canGoNextDocumentPage({
page: page.value,
pageSize: pageSize.value,
rowCount: documents.value.length,
paginationTotal: pageTotal.value,
}),
);
const documentRequestLimit = computed(() => {
if (documentStoreProvider.value.kind !== "elasticsearch" || paginationTotal.value === undefined) return pageSize.value;
return documentPageRequestLimit(page.value, pageSize.value, paginationTotal.value);
});
const tableFindPaneStyle = computed(() => {
if (tableFindPaneWidth.value == null) return {};
return { flex: `0 0 ${tableFindPaneWidth.value}px` };
});
const documentFontStyle = computed(() => documentViewerFontStyle(settingsStore.editorSettings));
const documentStoreLabels = computed(() => ({
documentsLabel: documentStoreProvider.value.documentsLabel({ total: total.value ?? 0, totalIsExact: totalIsExact.value, t }),
queryPreview: documentQueryPreview.value,
}));
type PendingDelete = { kind: "document"; index: number } | { kind: "field"; index: number; name: string };
type LocalFilterSummary = {
columnIndex: number;
columnName: string;
values: string[];
hiddenValueCount: number;
};
type DocumentFilterFieldTreeRow = DocumentFieldPathNode & { depth: number };
type DocumentGridChanges = {
dirtyRows: Map<number, Map<number, MongoInputValue>>;
deletedRows: Set<number>;
newRows: MongoInputValue[][];
columns: string[];
rows: MongoInputValue[][];
};
const documentFilterBuilderOpen = ref(false);
const documentFilterFieldPopoverOpen = ref<Record<string, boolean>>({});
const documentFilterFieldSearch = ref<Record<string, string>>({});
const documentFilterRules = ref<DocumentFilterRule[]>([]);
const appliedDocumentFilter = ref<Record<string, unknown> | null>(null);
const elasticsearchMappingFields = ref<ColumnInfo[]>([]);
const pendingDelete = ref<PendingDelete | null>(null);
const selectedDoc = computed(() => {
if (selectedIdx.value === null) return null;
return documents.value[selectedIdx.value] ?? null;
});
const documentSearchText = computed(() => editJson.value);
const documentSearchMatches = computed(() => findDocumentTextMatches(documentSearchText.value, documentSearchQuery.value));
const documentSearchActiveIndex = computed(() => {
if (documentSearchMatches.value.length === 0) return 0;
return Math.min(documentSearchMatchIndex.value, documentSearchMatches.value.length - 1);
});
const documentSearchStatus = computed(() => {
const count = documentSearchMatches.value.length;
return count > 0 ? `${documentSearchActiveIndex.value + 1}/${count}` : "0/0";
});
const editKeyWidth = computed(() => {
const longest = editFields.value.reduce((max, field) => {
return Math.max(max, Array.from(field.keyName || "").length);
}, 0);
return `${Math.min(Math.max(longest + 4, 8), 36)}ch`;
});
const deleteDetails = computed(() => {
const pending = pendingDelete.value;
if (!pending) return "";
if (pending.kind === "document") {
const id = documents.value[pending.index]?._id ?? "";
const displayId = mongoDocumentIdForGrid(id);
if (props.databaseType === "elasticsearch" || props.databaseType === "easysearch") {
const product = props.databaseType === "easysearch" ? "Easysearch" : "Elasticsearch";
return `${product} index: ${props.collection}\nDocument _id: ${String(displayId)}`;
}
return t("dangerDialog.mongoDocumentDetails", { collection: props.collection, id: String(displayId) });
}
return t("dangerDialog.mongoFieldDetails", { field: pending.name || t("mongo.field") });
});
const gridResult = computed<QueryResult>(() => {
const docs = documents.value;
if (!docs.length) {
return {
columns: lastGridColumns.value,
rows: [],
affected_rows: 0,
execution_time_ms: 0,
truncated: false,
};
}
const keySet = new Set<string>();
keySet.add("_id");
for (const doc of docs) {
for (const key of Object.keys(doc)) {
if (key !== "_id") keySet.add(key);
}
}
const columns = [...keySet];
const rows = docs.map((doc) =>
columns.map((col) => {
const val = mongoDocumentDisplayValue(doc[col]);
if (val === undefined || val === null) return null;
if (col === "_id") return documentStoreProvider.value.kind === "elasticsearch" ? documentStoreValueForGrid(val, "elasticsearch") : mongoDocumentIdForGrid(val);
if (typeof val === "object") return documentStoreValueForGrid(val, documentStoreProvider.value.kind);
if (typeof val === "string" || typeof val === "number" || typeof val === "boolean") return val;
return String(val);
}),
);
return { columns, rows, mongo_documents: docs, mongo_copy_documents: copyDocuments.value, affected_rows: 0, execution_time_ms: 0, truncated: false };
});
const expandedDocumentFilterFieldPaths = ref<Set<string>>(new Set());
const elasticsearchFieldTypes = computed(() => new Map(elasticsearchMappingFields.value.map((field) => [field.name, field.data_type])));
const elasticsearchFilterFieldNames = computed(() => {
const names = [...elasticsearchMappingFields.value.map((field) => field.name), ...gridResult.value.columns, "_id", "_routing"];
return [...new Set(names.filter(Boolean))];
});
const documentFilterFieldTree = computed<DocumentFieldPathNode[]>(() => {
if (documentStoreProvider.value.kind === "elasticsearch") {
return elasticsearchFieldPathTreeFromFieldNames(elasticsearchFilterFieldNames.value, elasticsearchFieldTypes.value);
}
const tree = documentFieldPathTreeFromDocuments(documents.value);
if (tree.length > 0) return tree;
return gridResult.value.columns.map((column) => ({
key: column,
path: column,
label: column,
displayPath: column,
kind: "scalar",
selectable: true,
children: [],
}));
});
const documentFilterFieldOptions = computed(() => {
if (documentStoreProvider.value.kind === "elasticsearch") {
return flattenDocumentFieldPathTree(documentFilterFieldTree.value)
.filter((field) => field.selectable)
.map((field) => field.path);
}
const nestedFields = documentFieldPathOptionsFromDocuments(documents.value);
return nestedFields.length > 0 ? nestedFields : gridResult.value.columns;
});
const documentFilterFieldRows = computed<DocumentFilterFieldTreeRow[]>(() => visibleDocumentFilterFieldRows(documentFilterFieldTree.value));
const documentFilterFieldByPath = computed(() => new Map(flattenDocumentFieldPathTree(documentFilterFieldTree.value).map((node) => [node.path, node])));
const documentStructuredFilterCount = computed(() => {
if (!appliedDocumentFilter.value) return 0;
if (documentStoreProvider.value.kind !== "elasticsearch") return 1;
const query = appliedDocumentFilter.value.$esQuery;
if (!query || typeof query !== "object" || Array.isArray(query)) return 0;
const bool = (query as Record<string, unknown>).bool;
if (!bool || typeof bool !== "object" || Array.isArray(bool)) return 0;
return elasticsearchBoolClauseOptions.reduce((count, clause) => {
const rules = (bool as Record<string, unknown>)[clause];
return count + (Array.isArray(rules) ? rules.length : 0);
}, 0);
});
const documentLoadingLabelKey = computed(() => (documentLoadCancelling.value ? "common.stopping" : "common.loading"));
let documentLoadingTimer: ReturnType<typeof setInterval> | undefined;
function createDocumentFilterRule(): DocumentFilterRule {
const fieldName = documentFilterFieldOptions.value[0] ?? "";
const rule = defaultDocumentFilterRule(uuid(), fieldName);
if (documentStoreProvider.value.kind === "elasticsearch") {
rule.elasticsearchQueryType = elasticsearchQueryTypeOptions(elasticsearchFieldTypes.value.get(fieldName))[0];
}
return rule;
}
function ensureDocumentFilterRule() {
if (documentFilterRules.value.length === 0 && documentFilterFieldOptions.value.length > 0) {
documentFilterRules.value = [createDocumentFilterRule()];
}
}
function addDocumentFilterRule() {
ensureDocumentFilterRule();
documentFilterRules.value = [...documentFilterRules.value, createDocumentFilterRule()];
}
function visibleDocumentFilterFieldRows(nodes: readonly DocumentFieldPathNode[], depth = 0): DocumentFilterFieldTreeRow[] {
const rows: DocumentFilterFieldTreeRow[] = [];
for (const node of nodes) {
rows.push({ ...node, depth });
if (node.children.length > 0 && expandedDocumentFilterFieldPaths.value.has(node.path)) {
rows.push(...visibleDocumentFilterFieldRows(node.children, depth + 1));
}
}
return rows;
}
function toggleDocumentFilterFieldExpanded(path: string) {
const next = new Set(expandedDocumentFilterFieldPaths.value);
if (next.has(path)) next.delete(path);
else next.add(path);
expandedDocumentFilterFieldPaths.value = next;
}
function setDocumentFilterFieldPopoverOpen(ruleId: string, open: boolean) {
const next = { ...documentFilterFieldPopoverOpen.value };
if (open) next[ruleId] = true;
else delete next[ruleId];
documentFilterFieldPopoverOpen.value = next;
if (open) {
void nextTick(() => document.getElementById(documentFilterFieldSearchInputId(ruleId))?.focus());
}
if (!open) {
const search = { ...documentFilterFieldSearch.value };
delete search[ruleId];
documentFilterFieldSearch.value = search;
}
}
function documentFilterFieldSearchInputId(ruleId: string): string {
return `document-filter-field-search-${ruleId}`;
}
function documentFilterFieldSearchActive(ruleId: string): boolean {
return !!documentFilterFieldSearch.value[ruleId]?.trim();
}
function documentFilterFieldRowsForRule(ruleId: string): DocumentFilterFieldTreeRow[] {
const query = documentFilterFieldSearch.value[ruleId] ?? "";
if (!query.trim()) return documentFilterFieldRows.value;
const matchingFields = documentStoreProvider.value.kind === "elasticsearch" ? searchElasticsearchFieldPathTree(documentFilterFieldTree.value, query) : searchDocumentFieldPathTree(documentFilterFieldTree.value, query);
return matchingFields.map((node) => ({ ...node, depth: 0 }));
}
function selectDocumentFilterField(ruleId: string, fieldName: string) {
updateDocumentFilterRule(ruleId, { fieldName });
setDocumentFilterFieldPopoverOpen(ruleId, false);
}
function documentFilterFieldLabel(path: string): string {
if (documentStoreProvider.value.kind !== "elasticsearch") {
return documentFilterFieldByPath.value.get(path)?.displayPath ?? path;
}
const fieldType = elasticsearchFieldTypes.value.get(path);
return fieldType ? `${path} (${fieldType})` : path;
}
function documentFilterFieldKindLabel(kind: DocumentFieldPathNode["kind"]): string {
if (kind === "array-object") return "array object";
return kind;
}
function removeDocumentFilterRule(ruleId: string) {
documentFilterRules.value = documentFilterRules.value.filter((rule) => rule.id !== ruleId);
setDocumentFilterFieldPopoverOpen(ruleId, false);
if (documentFilterRules.value.length === 0) appliedDocumentFilter.value = null;
}
function updateDocumentFilterRule(ruleId: string, patch: Partial<DocumentFilterRule>) {
documentFilterRules.value = documentFilterRules.value.map((rule) => {
if (rule.id !== ruleId) return rule;
const next = { ...rule, ...patch };
if (documentStoreProvider.value.kind === "elasticsearch") {
const queryTypes = elasticsearchQueryTypeOptions(elasticsearchFieldTypes.value.get(next.fieldName));
if (!next.elasticsearchQueryType || !queryTypes.includes(next.elasticsearchQueryType)) {
next.elasticsearchQueryType = queryTypes[0];
}
if (!elasticsearchQueryTypeNeedsValue(next.elasticsearchQueryType)) next.rawValue = "";
} else if (!documentFilterModeNeedsValue(next.mode)) {
next.rawValue = "";
}
return next;
});
}
function elasticsearchRuleQueryTypes(rule: DocumentFilterRule): ElasticsearchQueryType[] {
return elasticsearchQueryTypeOptions(elasticsearchFieldTypes.value.get(rule.fieldName));
}
function elasticsearchQueryTypeLabel(queryType: ElasticsearchQueryType): string {
const rangeOperator: Partial<Record<ElasticsearchQueryType, string>> = {
range_gt: "range >",
range_gte: "range >=",
range_lt: "range <",
range_lte: "range <=",
};
return rangeOperator[queryType] ?? queryType;
}
function resetDocumentFilterBuilder() {
appliedDocumentFilter.value = null;
documentFilterFieldPopoverOpen.value = {};
documentFilterFieldSearch.value = {};
documentFilterRules.value = documentFilterFieldOptions.value.length > 0 ? [createDocumentFilterRule()] : [];
}
function currentDocumentFilter(): string | undefined {
return currentDocumentFilterJson(filterInput.value, appliedDocumentFilter.value, documentStoreProvider.value.kind);
}
function resizeDocumentQueryInput(el: HTMLTextAreaElement | undefined) {
if (!el) return;
el.style.height = "auto";
el.style.height = `${Math.min(Math.max(el.scrollHeight, 20), 120)}px`;
}
function resizeDocumentQueryInputs() {
resizeDocumentQueryInput(filterInputRef.value);
resizeDocumentQueryInput(sortInputRef.value);
}
function formatFilterInput() {
try {
filterInput.value = formatDocumentQueryInput(filterInput.value, documentStoreProvider.value.kind);
error.value = "";
void nextTick(resizeDocumentQueryInputs);
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : String(e);
}
}
function formatSortInput() {
try {
sortInput.value = formatDocumentQueryInput(sortInput.value);
error.value = "";
void nextTick(resizeDocumentQueryInputs);
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : String(e);
}
}
watch([filterInput, sortInput], () => {
void nextTick(resizeDocumentQueryInputs);
});
const documentQueryPreview = computed(() => {
let filter = "{}";
try {
filter = currentDocumentFilter() ?? "{}";
} catch {
filter = filterInput.value.trim() || "{}";
}
return documentStoreProvider.value.queryPreview({
collection: props.collection,
filterJson: filter,
sortJson: sortInput.value.trim(),
skip: page.value * pageSize.value,
limit: documentRequestLimit.value,
});
});
async function applyDocumentStructuredFilters() {
if (documentStoreProvider.value.kind === "elasticsearch") {
appliedDocumentFilter.value = elasticsearchStructuredFilter(buildElasticsearchQueryFromRules(documentFilterRules.value));
documentFilterBuilderOpen.value = false;
applyFilter();
return;
}
const 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<string, unknown> } => !!item.condition);
const structured = combineDocumentFilterConditions(
items.map((item) => item.condition),
items.map((item) => item.rule),
items.map((item) => arrayObjectAncestorPathForDocumentField(documentFilterFieldTree.value, item.rule.fieldName)),
);
appliedDocumentFilter.value = structured;
documentFilterBuilderOpen.value = false;
applyFilter();
}
async function loadElasticsearchMappingFields() {
if (documentStoreProvider.value.kind !== "elasticsearch") return;
try {
elasticsearchMappingFields.value = await api.getColumns(props.connectionId, props.database, "", props.collection);
} catch {
elasticsearchMappingFields.value = [];
}
}
function clearDocumentFilters(clearLocalFilter?: (columnIndex?: number) => void) {
appliedDocumentFilter.value = null;
resetDocumentFilterBuilder();
clearLocalFilter?.();
applyFilter();
}
function documentIdFromGridValue(value: MongoInputValue | undefined): string | null {
if (value === null || value === undefined) return null;
if (typeof value === "string") {
const trimmed = value.trim();
if (!trimmed) return null;
if (trimmed.startsWith('"')) {
try {
const parsed = JSON.parse(trimmed);
return typeof parsed === "string" && parsed.trim() ? parsed : trimmed;
} catch {
return trimmed;
}
}
return trimmed;
}
const parsed = parseMongoDocumentInputValue(value);
if (parsed === null || parsed === undefined) return null;
const id = typeof parsed === "object" ? JSON.stringify(parsed) : String(parsed);
return id.trim() ? id : null;
}
function documentRoutingFromDocument(doc: JsonRecord | undefined): string | undefined {
return normalizeDocumentStoreRouting(doc?._routing);
}
function documentTypeFromDocument(doc: JsonRecord | undefined): string | undefined {
const documentType = doc?._type;
return typeof documentType === "string" && documentType.trim() ? documentType.trim() : undefined;
}
function documentRoutingFromGridRow(row: MongoInputValue[] | undefined, columns: string[]): string | undefined {
const routingColIdx = columns.indexOf("_routing");
return routingColIdx >= 0 ? normalizeDocumentStoreRouting(row?.[routingColIdx]) : undefined;
}
function documentStoreWriteApis(documentType?: string) {
return {
insert: (docJson: string, routing?: string) => api.documentInsertDocument(props.connectionId, props.database, props.collection, docJson, routing),
update: (id: string, docJson: string, routing?: string) => api.documentUpdateDocument(props.connectionId, props.database, props.collection, id, docJson, routing),
delete: (id: string, routing?: string) => api.documentDeleteDocument(props.connectionId, props.database, props.collection, id, routing, documentType),
};
}
async function gridSave(changes: DocumentGridChanges) {
const cols = changes.columns;
const idColIdx = cols.indexOf("_id");
if (idColIdx < 0) throw new Error("No _id column");
const isEs = documentStoreProvider.value.kind === "elasticsearch";
for (const [rowIdx, dirtyCols] of changes.dirtyRows) {
const row = changes.rows[rowIdx];
const id = row?.[idColIdx];
if (id == null) continue;
if (isEs) {
const doc = documents.value[rowIdx];
if (!doc) continue;
const routing = documentRoutingFromDocument(doc);
const updated = { ...doc };
for (const [colIdx, newVal] of dirtyCols) {
const col = cols[colIdx];
if (col === "_id" || col === "_routing") continue;
if (newVal === null) {
delete updated[col];
} else {
updated[col] = parseDocumentStoreInputValue(newVal, "elasticsearch");
}
}
await api.documentUpdateDocument(props.connectionId, props.database, props.collection, String(id), stringifyDocumentStoreValue(updated, "elasticsearch"), routing);
continue;
}
const updateDoc = buildMongoUpdateDocument(dirtyCols, cols, documents.value[rowIdx]);
if (Object.keys(updateDoc).length === 0) continue;
const documentId = documents.value[rowIdx]?._id ?? id;
await api.documentUpdateDocument(props.connectionId, props.database, props.collection, serializeMongoDocumentId(documentId), JSON.stringify(updateDoc));
}
for (const rowIdx of changes.deletedRows) {
const row = changes.rows[rowIdx];
const id = row?.[idColIdx];
if (id == null) continue;
const document = documents.value[rowIdx];
const routing = isEs ? documentRoutingFromDocument(document) : undefined;
const documentType = isEs ? documentTypeFromDocument(document) : undefined;
const documentId = isEs ? id : (document?._id ?? id);
await api.documentDeleteDocument(props.connectionId, props.database, props.collection, isEs ? String(documentId) : serializeMongoDocumentId(documentId), routing, documentType);
}
for (const newRow of changes.newRows) {
const doc = isEs ? buildElasticsearchInsertDocument(newRow, cols) : buildMongoInsertDocument(newRow, cols);
if (isEs) {
const id = documentIdFromGridValue(newRow[idColIdx]);
const routing = documentRoutingFromGridRow(newRow, cols);
if (id) {
await api.documentUpdateDocument(props.connectionId, props.database, props.collection, id, stringifyDocumentStoreValue(doc, "elasticsearch"), routing);
} else {
await api.documentInsertDocument(props.connectionId, props.database, props.collection, stringifyDocumentStoreValue(doc, "elasticsearch"), routing);
}
continue;
}
await api.documentInsertDocument(props.connectionId, props.database, props.collection, JSON.stringify(doc));
}
if (isEs) resetElasticsearchTotals({ preservePaginationTotal: true });
await load();
}
function buildElasticsearchInsertDocument(row: MongoInputValue[], columns: string[]): JsonRecord {
const doc: JsonRecord = {};
for (let columnIndex = 0; columnIndex < columns.length; columnIndex += 1) {
const column = columns[columnIndex];
if (!column || column === "_id" || column === "_routing") continue;
const value = row[columnIndex];
if (value !== null) doc[column] = parseDocumentStoreInputValue(value, "elasticsearch");
}
return doc;
}
function elasticsearchPathIdPreview(id: string): string {
return encodeURIComponent(id);
}
function elasticsearchRoutingPreview(routing: string | undefined): string {
return routing ? `?routing=${encodeURIComponent(routing)}` : "";
}
function buildElasticsearchPartialUpdateDocument(changes: Map<number, MongoInputValue>, columns: string[]): Record<string, unknown> {
const document: Record<string, unknown> = {};
for (const [colIdx, newVal] of changes) {
const col = columns[colIdx];
if (col === "_id" || col === "_routing") continue;
if (col && newVal !== null) document[col] = parseDocumentStoreInputValue(newVal, "elasticsearch");
}
return document;
}
async function previewDocumentChanges(changes: DocumentGridChanges): Promise<string[]> {
const { dirtyRows, deletedRows, newRows, columns, rows } = changes;
const idColIdx = columns.indexOf("_id");
const stmts: string[] = [];
const coll = props.collection;
const isEs = documentStoreProvider.value.kind === "elasticsearch";
for (const [rowIdx, dirtyCols] of dirtyRows) {
const row = rows[rowIdx];
const id = row?.[idColIdx];
if (id == null) continue;
if (isEs) {
const updateDoc = buildElasticsearchPartialUpdateDocument(dirtyCols, columns);
const routing = documentRoutingFromGridRow(row, columns);
stmts.push(`POST /${coll}/_update/${elasticsearchPathIdPreview(String(id))}${elasticsearchRoutingPreview(routing)}\n${stringifyDocumentStoreValue({ doc: updateDoc.$set ?? updateDoc }, "elasticsearch", 2)}`);
} else {
const updateDoc = buildMongoUpdateDocument(dirtyCols, columns, documents.value[rowIdx]);
stmts.push(`db.${coll}.updateOne({_id: ${formatMongoShellLiteral(documents.value[rowIdx]?._id ?? id)}}, ${formatMongoShellLiteral(updateDoc)})`);
}
}
for (const rowIdx of deletedRows) {
const row = rows[rowIdx];
const id = row?.[idColIdx];
if (id == null) continue;
if (isEs) {
const routing = documentRoutingFromGridRow(row, columns);
stmts.push(`DELETE /${coll}/_doc/${elasticsearchPathIdPreview(String(id))}${elasticsearchRoutingPreview(routing)}`);
} else {
stmts.push(`db.${coll}.deleteOne({_id: ${formatMongoShellLiteral(documents.value[rowIdx]?._id ?? id)}})`);
}
}
for (const newRow of newRows) {
const doc = isEs ? buildElasticsearchInsertDocument(newRow, columns) : buildMongoInsertDocument(newRow, columns);
if (isEs) {
const id = idColIdx >= 0 ? documentIdFromGridValue(newRow[idColIdx]) : null;
if (id) {
stmts.push(`PUT /${coll}/_doc/${elasticsearchPathIdPreview(id)}\n${stringifyDocumentStoreValue(doc, "elasticsearch", 2)}`);
} else {
stmts.push(`POST /${coll}/_doc\n${stringifyDocumentStoreValue(doc, "elasticsearch", 2)}`);
}
} else {
stmts.push(`db.${coll}.insertOne(${formatMongoShellLiteral(doc)})`);
}
}
return stmts;
}
const customSaveHandler = computed<CustomSaveHandler>(() => ({
save: gridSave,
preview: previewDocumentChanges,
supportsInsert: true,
readonlyColumns: documentStoreProvider.value.kind === "elasticsearch" ? ["_routing"] : undefined,
targetLabel: props.collection,
}));
function stopDocumentLoadingTimer() {
if (documentLoadingTimer) clearInterval(documentLoadingTimer);
documentLoadingTimer = undefined;
}
function startDocumentLoadingTimer() {
stopDocumentLoadingTimer();
const startedAt = Date.now();
documentLoadingElapsedSeconds.value = "0.0";
documentLoadingTimer = setInterval(() => {
documentLoadingElapsedSeconds.value = ((Date.now() - startedAt) / 1000).toFixed(1);
}, 100);
}
function elasticsearchCountFilterKey(filter: string | undefined): string {
return JSON.stringify([props.connectionId, props.database, props.collection, filter ?? ""]);
}
function isCurrentDocumentQueryTotalCountRequest(request: LoadedDocumentQueryTotalCountRequest): boolean {
if (request.generation !== documentRequestGeneration || request.connectionId !== props.connectionId || request.database !== props.database || request.collection !== props.collection || request.storeKind !== documentStoreProvider.value.kind) {
return false;
}
return loadedDocumentQueryTotalCountRequest !== undefined && isSameDocumentQueryTotalCountRequest(request, loadedDocumentQueryTotalCountRequest) && request.storeKind === loadedDocumentQueryTotalCountRequest.storeKind;
}
function cancelElasticsearchCount() {
elasticsearchCountGeneration++;
const executionId = elasticsearchCountExecutionId;
elasticsearchCountExecutionId = "";
if (executionId) void api.cancelQuery(executionId);
}
function resetElasticsearchTotals(options: { preservePaginationTotal?: boolean } = {}) {
const nextTotals = resetElasticsearchDocumentTotals(paginationTotal.value, options.preservePaginationTotal);
cancelElasticsearchCount();
elasticsearchCountKey = null;
elasticsearchExactTotal = undefined;
elasticsearchPaginationLowerBound = undefined;
paginationTotal.value = nextTotals.paginationTotal;
total.value = nextTotals.total;
totalIsExact.value = nextTotals.totalIsExact;
}
function clampPageToPaginationTotal(): number | undefined {
const cap = paginationTotal.value;
if (cap === undefined) return undefined;
const nextPage = clampDocumentPage(page.value, pageSize.value, cap);
if (page.value === nextPage) return undefined;
return nextPage;
}
function startElasticsearchExactCount(filter: string | undefined) {
if (elasticsearchCountExecutionId || elasticsearchExactTotal !== undefined || !elasticsearchCountKey) return;
const key = elasticsearchCountKey;
const executionId = uuid();
const generation = elasticsearchCountGeneration;
elasticsearchCountExecutionId = executionId;
void api
.elasticsearchCountDocuments(props.connectionId, props.collection, filter, executionId)
.then((exactCount) => {
if (generation !== elasticsearchCountGeneration || key !== elasticsearchCountKey || executionId !== elasticsearchCountExecutionId || !Number.isFinite(exactCount) || exactCount < 0) {
return;
}
elasticsearchExactTotal = exactCount;
const totals = resolveElasticsearchDocumentTotals(elasticsearchPaginationLowerBound ?? exactCount, false, exactCount);
total.value = totals.total;
totalIsExact.value = totals.totalIsExact;
paginationTotal.value = totals.paginationTotal;
const clampedPage = clampPageToPaginationTotal();
if (clampedPage !== undefined) void load({ page: clampedPage });
})
.catch(() => {
// The lower-bound result remains truthful when a background count fails.
})
.finally(() => {
if (generation === elasticsearchCountGeneration && executionId === elasticsearchCountExecutionId) {
elasticsearchCountExecutionId = "";
}
});
}
function applyElasticsearchSearchTotal(searchTotal: number, isExact: boolean, filter: string | undefined) {
const key = elasticsearchCountFilterKey(filter);
if (key !== elasticsearchCountKey) {
cancelElasticsearchCount();
elasticsearchCountKey = key;
elasticsearchExactTotal = undefined;
elasticsearchPaginationLowerBound = undefined;
}
elasticsearchPaginationLowerBound = searchTotal;
const totals = resolveElasticsearchDocumentTotals(searchTotal, isExact, elasticsearchExactTotal);
if (isExact) {
cancelElasticsearchCount();
elasticsearchExactTotal = searchTotal;
total.value = totals.total;
totalIsExact.value = totals.totalIsExact;
paginationTotal.value = totals.paginationTotal;
return;
}
if (elasticsearchExactTotal !== undefined) {
total.value = totals.total;
totalIsExact.value = totals.totalIsExact;
paginationTotal.value = totals.paginationTotal;
return;
}
total.value = totals.total;
totalIsExact.value = totals.totalIsExact;
paginationTotal.value = totals.paginationTotal;
startElasticsearchExactCount(filter);
}
async function load(options: { page?: number } = {}) {
if (documentLoadExecutionId.value) void api.cancelQuery(documentLoadExecutionId.value);
const requestGeneration = ++documentRequestGeneration;
const executionId = uuid();
loading.value = true;
documentLoadExecutionId.value = executionId;
documentLoadCancelling.value = false;
startDocumentLoadingTimer();
error.value = "";
const requestPage = options.page ?? page.value;
const previousSelectedIdx = selectedIdx.value;
const previousSelectedId = previousSelectedIdx === null ? null : documentIdentity(documents.value[previousSelectedIdx]);
try {
const connectionId = props.connectionId;
const database = props.database;
const collection = props.collection;
const storeKind = documentStoreProvider.value.kind;
const filter = currentDocumentFilter();
const countRequest: LoadedDocumentQueryTotalCountRequest = { connectionId, database, collection, filter, generation: requestGeneration, storeKind };
if (storeKind === "elasticsearch" && elasticsearchCountKey !== null && elasticsearchCountKey !== elasticsearchCountFilterKey(filter)) {
resetElasticsearchTotals();
}
const sort = currentDocumentSortJson(sortInput.value);
const skip = requestPage * pageSize.value;
const result = await api.documentFindDocuments(connectionId, database, collection, skip, documentRequestLimit.value, filter, undefined, sort, undefined, executionId);
if (documentLoadExecutionId.value !== executionId) return;
if (connectionId !== props.connectionId || database !== props.database || collection !== props.collection || storeKind !== documentStoreProvider.value.kind) return;
const nextDocuments =
storeKind === "elasticsearch" && result.raw_documents?.length === result.documents.length
? result.raw_documents.map((raw, index) => {
try {
return asRecord(parseJsonPreservingLargeNumbers(raw));
} catch {
return asRecord(result.documents[index]);
}
})
: result.documents.map(asRecord);
const hasTypePreservingCopyDocuments = result.extended_documents?.length === nextDocuments.length;
const nextCopyDocuments = hasTypePreservingCopyDocuments ? result.extended_documents!.map(asRecord) : nextDocuments;
// Commit page + rows together so stale rows never briefly show last-page indexes.
if (options.page !== undefined) page.value = options.page;
documents.value = nextDocuments;
copyDocuments.value = nextCopyDocuments;
mongoCopyDocumentsAvailable.value = hasTypePreservingCopyDocuments;
loadedDocumentQueryTotalCountRequest = countRequest;
if (nextDocuments.length > 0) {
const keySet = new Set<string>();
keySet.add("_id");
for (const doc of nextDocuments) {
for (const key of Object.keys(doc)) {
if (key !== "_id") keySet.add(key);
}
}
lastGridColumns.value = [...keySet];
}
if (storeKind === "elasticsearch") {
applyElasticsearchSearchTotal(result.total, result.total_is_exact !== false, filter);
} else {
cancelElasticsearchCount();
const totals = resolveDocumentQueryTotals(result.total, result.total_is_exact !== false, {
page: requestPage,
pageSize: pageSize.value,
rowCount: nextDocuments.length,
});
total.value = totals.total;
totalIsExact.value = totals.totalIsExact;
paginationTotal.value = totals.paginationTotal;
}
syncSelectedDocumentAfterLoad(previousSelectedIdx, previousSelectedId);
} catch (e: unknown) {
if (documentLoadExecutionId.value === executionId) error.value = e instanceof Error ? e.message : String(e);
} finally {
if (documentLoadExecutionId.value === executionId) {
loading.value = false;
documentLoadExecutionId.value = "";
documentLoadCancelling.value = false;
stopDocumentLoadingTimer();
}
}
}
async function countExactDocumentTotal(): Promise<number | undefined> {
const request = loadedDocumentQueryTotalCountRequest;
if (!request || !isCurrentDocumentQueryTotalCountRequest(request)) return undefined;
if (request.storeKind === "elasticsearch") {
const exactCount = await api.elasticsearchCountDocuments(request.connectionId, request.collection, request.filter);
if (!isCurrentDocumentQueryTotalCountRequest(request)) return undefined;
if (!Number.isFinite(exactCount) || exactCount < 0) {
throw new Error("invalid count");
}
elasticsearchExactTotal = exactCount;
const totals = resolveElasticsearchDocumentTotals(elasticsearchPaginationLowerBound ?? exactCount, false, exactCount);
total.value = totals.total;
totalIsExact.value = totals.totalIsExact;
paginationTotal.value = totals.paginationTotal;
return exactCount;
}
const exactCount = await api.mongoCountDocuments(request.connectionId, request.database, request.collection, request.filter, "accurate");
if (!isCurrentDocumentQueryTotalCountRequest(request)) return undefined;
if (!Number.isFinite(exactCount) || exactCount < 0) {
throw new Error("invalid count");
}
const totals = resolveDocumentQueryTotals(exactCount, true);
total.value = totals.total;
totalIsExact.value = totals.totalIsExact;
paginationTotal.value = totals.paginationTotal;
return exactCount;
}
async function refreshDocuments() {
if (documentStoreProvider.value.kind === "elasticsearch") resetElasticsearchTotals({ preservePaginationTotal: true });
await load();
}
async function cancelDocumentLoad() {
const executionId = documentLoadExecutionId.value;
if (!executionId || documentLoadCancelling.value) return;
documentLoadCancelling.value = true;
try {
await api.cancelQuery(executionId);
} finally {
if (documentLoadExecutionId.value === executionId) {
loading.value = false;
documentLoadExecutionId.value = "";
documentLoadCancelling.value = false;
stopDocumentLoadingTimer();
}
}
}
function applyFilter() {
page.value = 0;
if (documentStoreProvider.value.kind === "elasticsearch") resetElasticsearchTotals();
void load();
}
function paginate(offset: number, limit: number) {
const normalizedLimit = normalizeResultPageSize(limit, pageSize.value);
pageSize.value = normalizedLimit;
const requestedPage = Math.floor(Math.max(0, offset) / normalizedLimit);
const nextPage = clampDocumentPage(requestedPage, normalizedLimit, paginationTotal.value);
void load({ page: nextPage });
}
function onSort(column: string, _columnIndex: number, direction: "asc" | "desc" | null) {
sortInput.value = documentStoreProvider.value.sortInputForColumn(column, direction);
page.value = 0;
void load();
}
function asRecord(value: unknown): JsonRecord {
if (value && typeof value === "object" && !Array.isArray(value)) {
return value as JsonRecord;
}
return {};
}
function documentIdentity(doc: JsonRecord | undefined): string | null {
const id = doc?._id;
if (id === null || id === undefined) return null;
return typeof id === "object" ? JSON.stringify(id) : String(id);
}
function syncSelectedDocumentAfterLoad(previousSelectedIdx: number | null, previousSelectedId: string | null) {
if (isNew.value || previousSelectedIdx === null) return;
if (!documents.value.length) {
selectedIdx.value = null;
if (!isEditing.value) editJson.value = "";
return;
}
const nextIdx = previousSelectedId ? documents.value.findIndex((doc) => documentIdentity(doc) === previousSelectedId) : previousSelectedIdx < documents.value.length ? previousSelectedIdx : -1;
if (nextIdx < 0) {
selectedIdx.value = null;
if (!isEditing.value) editJson.value = "";
return;
}
selectedIdx.value = nextIdx;
if (!isEditing.value) {
editJson.value = stringifyDocumentStoreValue(documents.value[nextIdx], documentStoreProvider.value.kind, 2);
}
}
function emptyDocumentJson(): string {
return stringifyDocumentStoreValue({}, documentStoreProvider.value.kind, 2);
}
function documentEditErrorMessage(result: { error: "empty" | "invalid" | "not-object" | "unsupported-number" } | { error: "duplicate-key"; field: string }): string {
if (result.error === "not-object") return t("mongo.documentMustBeObject");
if (result.error === "unsupported-number") return t("mongo.unsupportedJsonNumber");
if (result.error === "duplicate-key") return t("mongo.duplicateJsonKey", { field: result.field });
return t("mongo.invalidJson");
}
function buildEditFieldsFromDocument(doc: JsonRecord): EditNode[] {
return Object.entries(doc).map(([name, value]) => {
const isMetadata = isDocumentStoreIdentityField(documentStoreProvider.value.kind, name);
// Metadata field names stay fixed; values are editable so _id / routing rekey is possible.
return createEditNode(name, value, isMetadata, false);
});
}
function metadataFieldsFromDocument(doc: JsonRecord | undefined): JsonRecord {
const metadata: JsonRecord = {};
if (!doc) return metadata;
if (Object.prototype.hasOwnProperty.call(doc, "_id")) metadata._id = doc._id;
if (documentStoreProvider.value.kind === "elasticsearch" && Object.prototype.hasOwnProperty.call(doc, "_routing")) {
metadata._routing = doc._routing;
}
return metadata;
}
function currentDocumentMetadata(): JsonRecord {
if (selectedDoc.value) return metadataFieldsFromDocument(selectedDoc.value);
// New documents keep metadata that already exists in either editor mode.
if (documentEditMode.value === "json") {
const parsed = parseDocumentStoreJsonDocument(editJson.value, documentStoreProvider.value.kind);
return parsed.ok ? metadataFieldsFromDocument(parsed.document) : {};
}
const metadata: JsonRecord = {};
for (const field of editFields.value) {
const name = field.keyName.trim();
if (isDocumentStoreIdentityField(documentStoreProvider.value.kind, name)) {
metadata[name] = buildValueFromNode(field, name);
}
}
return metadata;
}
function syncEditJsonFromFields() {
const doc = buildDocumentFromFields();
// Field mode omits root metadata keys; restore them for JSON round-trips (new + existing).
Object.assign(doc, currentDocumentMetadata());
editJson.value = stringifyDocumentStoreValue(doc, documentStoreProvider.value.kind, 2);
}
function setDocumentEditMode(mode: "fields" | "json") {
if (!isEditing.value || documentEditMode.value === mode) return;
error.value = "";
if (mode === "json") {
try {
syncEditJsonFromFields();
documentEditMode.value = "json";
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : String(e);
}
return;
}
const parsed = parseDocumentStoreJsonDocument(editJson.value, documentStoreProvider.value.kind);
if (!parsed.ok) {
error.value = documentEditErrorMessage(parsed);
return;
}
editFields.value = buildEditFieldsFromDocument(parsed.document);
documentEditMode.value = "fields";
}
function selectDoc(idx: number) {
selectedIdx.value = idx;
editJson.value = stringifyDocumentStoreValue(documents.value[idx], documentStoreProvider.value.kind, 2);
isEditing.value = false;
isNew.value = false;
documentEditMode.value = "fields";
editFields.value = [];
error.value = "";
}
function startNew() {
selectedIdx.value = null;
editJson.value = emptyDocumentJson();
editFields.value = [createEditNode("", "", false, false)];
documentEditMode.value = "json";
isEditing.value = true;
isNew.value = true;
error.value = "";
}
function startEdit() {
const doc = selectedDoc.value;
if (!doc) return;
// Issue #2952: open whole-document JSON editing by default (DBeaver-style), not field tree.
editJson.value = stringifyDocumentStoreValue(doc, documentStoreProvider.value.kind, 2);
editFields.value = buildEditFieldsFromDocument(doc);
documentEditMode.value = "json";
isEditing.value = true;
isNew.value = false;
error.value = "";
}
function cancelEdit() {
isEditing.value = false;
documentEditMode.value = "fields";
if (isNew.value) {
isNew.value = false;
editFields.value = [];
editJson.value = "";
error.value = "";
return;
}
if (selectedDoc.value) {
editJson.value = stringifyDocumentStoreValue(selectedDoc.value, documentStoreProvider.value.kind, 2);
}
editFields.value = [];
documentEditMode.value = "fields";
error.value = "";
}
function createEditNode(keyName: string, value: unknown, readonlyKey: boolean, readonlyValue: boolean): EditNode {
if (isLosslessJsonNumber(value)) {
return {
key: uuid(),
keyName,
kind: "value",
valueText: value.raw,
readonlyKey,
readonlyValue,
children: [],
};
}
if (Array.isArray(value)) {
return {
key: uuid(),
keyName,
kind: "array",
valueText: "",
readonlyKey,
readonlyValue,
children: value.map((child, idx) => createEditNode(String(idx), child, true, readonlyValue)),
};
}
if (value && typeof value === "object") {
return {
key: uuid(),
keyName,
kind: "object",
valueText: "",
readonlyKey,
readonlyValue,
children: Object.entries(value as JsonRecord).map(([childName, child]) => createEditNode(childName, child, readonlyValue, readonlyValue)),
};
}
return {
key: uuid(),
keyName,
kind: "value",
valueText: formatForEdit(value),
readonlyKey,
readonlyValue,
children: [],
};
}
function addField() {
editFields.value.push(createEditNode("", "", false, false));
}
function applyRemoveField(idx: number) {
if (editFields.value[idx]?.readonlyValue) return;
editFields.value.splice(idx, 1);
}
function requestRemoveField(idx: number) {
const field = editFields.value[idx];
if (!field || field.readonlyValue) return;
pendingDelete.value = { kind: "field", index: idx, name: field.keyName };
showDeleteConfirm.value = true;
}
function formatForEdit(value: unknown): string {
value = mongoDocumentDisplayValue(value);
if (value === undefined) return "";
if (value === null) return "null";
if (typeof value === "string") return JSON.stringify(value);
if (typeof value === "object") return stringifyDocumentStoreValue(value, documentStoreProvider.value.kind, 2);
return String(value);
}
function parseFieldValue(raw: string): unknown {
return parseDocumentStoreInputValue(raw, documentStoreProvider.value.kind);
}
function buildObjectFromNodes(nodes: EditNode[], path: string): JsonRecord {
const doc: JsonRecord = {};
const seen = new Set<string>();
for (const field of nodes) {
const name = field.keyName.trim();
if (!name || (!path && isDocumentStoreIdentityField(documentStoreProvider.value.kind, name))) continue;
if (seen.has(name)) throw new Error(t("mongo.duplicateField", { field: name }));
seen.add(name);
doc[name] = buildValueFromNode(field, path ? `${path}.${name}` : name);
}
return doc;
}
function buildValueFromNode(node: EditNode, path: string): unknown {
if (node.kind === "value") return parseFieldValue(node.valueText);
if (node.kind === "array") {
return node.children.map((child, idx) => buildValueFromNode(child, `${path}[${idx}]`));
}
return buildObjectFromNodes(node.children, path);
}
function buildDocumentFromFields(): JsonRecord {
return buildObjectFromNodes(editFields.value, "");
}
function buildDocumentFromEditor(): JsonRecord | null {
if (documentEditMode.value === "json") {
const parsed = parseDocumentStoreJsonDocument(editJson.value, documentStoreProvider.value.kind);
if (!parsed.ok) {
error.value = documentEditErrorMessage(parsed);
return null;
}
return parsed.document;
}
// Field mode skips root metadata in buildDocumentFromFields(); reattach identity field values.
const doc = buildDocumentFromFields();
for (const field of editFields.value) {
const name = field.keyName.trim();
if (isDocumentStoreIdentityField(documentStoreProvider.value.kind, name)) {
doc[name] = buildValueFromNode(field, name);
}
}
return doc;
}
function resolveDocumentStorePathId(id: unknown): string | null {
if (documentStoreProvider.value.kind === "elasticsearch") {
return documentIdFromGridValue(documentStoreValueForGrid(id, "elasticsearch"));
}
if (id === undefined || id === null || id === "") return null;
try {
const serialized = serializeDocumentStoreId(id, documentStoreProvider.value.kind);
return serialized.trim() ? serialized : null;
} catch {
return null;
}
}
function resolveWriteIdentityFromEditor(doc: JsonRecord, currentId: unknown, currentRouting: string | undefined): { writeId: string; writeRouting?: string } | null {
const kind = documentStoreProvider.value.kind;
const hasPayloadId = Object.prototype.hasOwnProperty.call(doc, "_id");
const writeId = hasPayloadId ? resolveDocumentStorePathId(doc._id) : resolveDocumentStorePathId(currentId);
if (!writeId) return null;
const writeRouting = kind === "elasticsearch" ? resolveDocumentStoreWriteRouting(doc, currentRouting) : undefined;
return { writeId, writeRouting };
}
async function saveDoc() {
if (isSavingDocument.value) return;
error.value = "";
isSavingDocument.value = true;
try {
const doc = buildDocumentFromEditor();
if (!doc) return;
const kind = documentStoreProvider.value.kind;
if (isNew.value) {
const apis = documentStoreWriteApis();
const explicitId = kind === "elasticsearch" ? documentIdFromGridValue(documentStoreValueForGrid(doc._id, "elasticsearch")) : null;
await insertDocumentStoreDocumentCore({
kind,
document: doc,
explicitId,
routing: normalizeDocumentStoreRouting(doc._routing),
apis,
});
} else if (selectedIdx.value !== null) {
const current = documents.value[selectedIdx.value];
const currentId = current?._id;
if (currentId === undefined || currentId === null) {
error.value = "No _id field";
return;
}
const deleteId = resolveDocumentStorePathId(currentId);
if (!deleteId) {
error.value = "No _id field";
return;
}
const currentRouting = documentRoutingFromDocument(current);
const apis = documentStoreWriteApis(kind === "elasticsearch" ? documentTypeFromDocument(current) : undefined);
const write = resolveWriteIdentityFromEditor(doc, currentId, currentRouting);
if (!write) {
error.value = t("mongo.jsonIdRequired");
return;
}
const plan = planDocumentStoreIdentityMigration({
write: { id: write.writeId, routing: write.writeRouting },
current: { id: deleteId, routing: kind === "elasticsearch" ? currentRouting : undefined },
});
// Rekey writes first then deletes; write failure leaves the old document intact.
await applyDocumentStoreIdentityPlan({ kind, plan, document: doc, apis });
} else {
return;
}
isEditing.value = false;
isNew.value = false;
documentEditMode.value = "fields";
editFields.value = [];
if (kind === "elasticsearch") resetElasticsearchTotals({ preservePaginationTotal: true });
await load();
if (selectedIdx.value !== null && documents.value[selectedIdx.value]) {
editJson.value = stringifyDocumentStoreValue(documents.value[selectedIdx.value], documentStoreProvider.value.kind, 2);
}
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : String(e);
} finally {
isSavingDocument.value = false;
}
}
async function applyDeleteDoc(idx: number) {
const doc = documents.value[idx];
const id = doc._id;
if (!id) return;
error.value = "";
try {
await api.documentDeleteDocument(props.connectionId, props.database, props.collection, serializeDocumentStoreId(id, documentStoreProvider.value.kind), documentRoutingFromDocument(doc), documentStoreProvider.value.kind === "elasticsearch" ? documentTypeFromDocument(doc) : undefined);
if (selectedIdx.value === idx) {
selectedIdx.value = null;
editJson.value = "";
}
if (documentStoreProvider.value.kind === "elasticsearch") resetElasticsearchTotals({ preservePaginationTotal: true });
await load();
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : String(e);
}
}
function requestDeleteDoc(idx: number) {
pendingDelete.value = { kind: "document", index: idx };
showDeleteConfirm.value = true;
}
async function confirmDelete() {
const pending = pendingDelete.value;
if (!pending) return;
if (pending.kind === "document") {
await applyDeleteDoc(pending.index);
} else {
applyRemoveField(pending.index);
}
pendingDelete.value = null;
}
function prevPage() {
if (page.value <= 0) return;
page.value--;
void load();
}
function nextPage() {
if (!canGoNextPage.value) return;
page.value++;
void load();
}
function docPreview(doc: JsonRecord): string {
const id = doc._id || "";
const keys = Object.keys(doc)
.filter((k) => k !== "_id")
.slice(0, 3);
const preview = keys.map((k) => `${k}: ${stringifyDocumentStoreValue(doc[k], documentStoreProvider.value.kind).substring(0, 30)}`).join(", ");
return `${id} - ${preview}`;
}
function highlightedJson(json: string): string {
return renderDocumentJsonHtml(json, documentSearchOpen.value ? documentSearchQuery.value : "", documentSearchActiveIndex.value);
}
function handleDocumentBrowserPointerDown(event: PointerEvent) {
const target = event.target;
documentViewerSearchActive.value = target instanceof Element && !!target.closest("[data-document-json-viewer], [data-document-search]");
}
function focusSearch(): boolean {
if (viewMode.value !== "document" || !documentViewerSearchActive.value) return false;
if (isEditing.value) return false;
if (!isNew.value && selectedIdx.value === null) return false;
documentSearchOpen.value = true;
documentSearchHasNavigated.value = false;
void nextTick(() => {
documentSearchInputRef.value?.focus();
documentSearchInputRef.value?.select();
});
return true;
}
function closeDocumentSearch() {
documentSearchOpen.value = false;
void nextTick(() => {
documentViewerRef.value?.focus();
});
}
function moveDocumentSearchMatch(delta: -1 | 1) {
const count = documentSearchMatches.value.length;
if (count === 0) return;
documentSearchMatchIndex.value = (documentSearchActiveIndex.value + delta + count) % count;
documentSearchHasNavigated.value = true;
void scrollDocumentSearchMatchIntoView();
}
function activateDocumentSearchMatch(delta: -1 | 1) {
if (documentSearchMatches.value.length === 0) return;
if (!documentSearchHasNavigated.value) {
documentSearchHasNavigated.value = true;
void scrollDocumentSearchMatchIntoView();
return;
}
moveDocumentSearchMatch(delta);
}
async function scrollDocumentSearchMatchIntoView() {
await nextTick();
documentViewerRef.value?.querySelector<HTMLElement>('[data-document-search-active="true"]')?.scrollIntoView({ block: "center", inline: "nearest" });
}
watch([documentSearchQuery, documentSearchText], () => {
documentSearchMatchIndex.value = 0;
documentSearchHasNavigated.value = false;
void scrollDocumentSearchMatchIntoView();
});
watch([viewMode, isEditing, selectedIdx], ([mode, editing, index]) => {
if (mode === "document" && !editing && index !== null) return;
documentViewerSearchActive.value = false;
documentSearchOpen.value = false;
});
onMounted(async () => {
window.addEventListener("pointerdown", handleDocumentBrowserPointerDown, true);
try {
await connectionStore.ensureConnected(props.connectionId);
} catch (e) {
console.warn("[DBX] ensureConnected failed for", props.connectionId, e);
}
// Mapping metadata enriches the filter builder, but it must not delay the
// first page of documents when the mapping endpoint is slow.
void loadElasticsearchMappingFields();
void load();
void nextTick(resizeDocumentQueryInputs);
});
onBeforeUnmount(() => {
window.removeEventListener("pointerdown", handleDocumentBrowserPointerDown, true);
if (documentLoadExecutionId.value) void api.cancelQuery(documentLoadExecutionId.value);
documentRequestGeneration++;
loadedDocumentQueryTotalCountRequest = undefined;
cancelElasticsearchCount();
stopDocumentLoadingTimer();
endTableSearchSplitResize();
});
function tableSearchSplitContainerWidth(): number {
return tableSearchSplitContainerRef.value?.getBoundingClientRect().width ?? 0;
}
function startTableSearchSplitResize(event: MouseEvent) {
const containerWidth = tableSearchSplitContainerWidth();
if (containerWidth <= 0) return;
event.preventDefault();
isResizingTableSearchSplit.value = true;
tableSearchSplitStartX = event.clientX;
tableSearchSplitStartWidth = clampSearchSplitWidth({
containerWidth,
desiredWidth: tableFindPaneWidth.value ?? undefined,
});
tableFindPaneWidth.value = tableSearchSplitStartWidth;
document.body.classList.add("select-none", "cursor-col-resize");
window.addEventListener("mousemove", moveTableSearchSplitResize);
window.addEventListener("mouseup", endTableSearchSplitResize);
}
function moveTableSearchSplitResize(event: MouseEvent) {
if (!isResizingTableSearchSplit.value) return;
const containerWidth = tableSearchSplitContainerWidth();
if (containerWidth <= 0) return;
tableFindPaneWidth.value = clampSearchSplitWidth({
containerWidth,
desiredWidth: tableSearchSplitStartWidth + event.clientX - tableSearchSplitStartX,
});
}
function endTableSearchSplitResize() {
isResizingTableSearchSplit.value = false;
document.body.classList.remove("select-none", "cursor-col-resize");
window.removeEventListener("mousemove", moveTableSearchSplitResize);
window.removeEventListener("mouseup", endTableSearchSplitResize);
}
function resetTableSearchSplitWidth() {
const containerWidth = tableSearchSplitContainerWidth();
tableFindPaneWidth.value = containerWidth > 0 ? clampSearchSplitWidth({ containerWidth }) : null;
}
defineExpose({ focusSearch });
</script>
<template>
<div class="h-full flex flex-col overflow-hidden">
<!-- Top toolbar: view toggle + document count + pagination + actions -->
<div class="h-9 flex items-center gap-1 px-3 border-b shrink-0 text-xs text-muted-foreground">
<div class="flex items-center border rounded-md overflow-hidden mr-2">
<Button variant="ghost" size="icon" class="h-5 w-5 rounded-none" :class="{ 'bg-accent': viewMode === 'document' }" :title="t('mongo.documentView')" @click="viewMode = 'document'">
<Braces class="h-3 w-3" />
</Button>
<Button variant="ghost" size="icon" class="h-5 w-5 rounded-none" :class="{ 'bg-accent': viewMode === 'table' }" :title="t('mongo.tableView')" @click="viewMode = 'table'">
<Table2 class="h-3 w-3" />
</Button>
</div>
<span class="shrink-0 ml-1">{{ documentStoreLabels.documentsLabel }}</span>
<Button v-if="viewMode === 'document'" variant="ghost" size="icon" class="h-5 w-5" @click="startNew"><Plus class="h-3 w-3" /></Button>
<Button v-if="viewMode === 'document'" variant="ghost" size="icon" class="h-5 w-5" @click="refreshDocuments"><RefreshCw class="h-3 w-3" :class="{ 'animate-spin': loading }" /></Button>
<div v-if="viewMode === 'document'" class="flex items-center gap-1 ml-1">
<Button variant="ghost" size="icon" class="h-5 w-5" :disabled="page <= 0" @click="prevPage">
<ChevronLeft class="h-3 w-3" />
</Button>
<span v-if="documentPageCount !== undefined">{{ page + 1 }} / {{ documentPageCount }}</span>
<span v-else>{{ page + 1 }}</span>
<Button variant="ghost" size="icon" class="h-5 w-5" :disabled="!canGoNextPage" @click="nextPage">
<ChevronRight class="h-3 w-3" />
</Button>
</div>
<div class="flex-1" />
<DataGridColumnLayoutPopover v-if="viewMode === 'table' && gridResult.columns.length" :grid="dataGridRef" />
<Popover v-if="viewMode === 'table' && gridResult.columns.length" v-model:open="viewOptionsOpen">
<PopoverTrigger as-child>
<Button variant="ghost" size="icon" class="h-6 w-7 shrink-0 text-foreground hover:bg-accent" :class="{ 'bg-accent text-foreground': dataGridRef?.nullColumnsHidden }" :title="t('grid.viewOptions')" :aria-label="t('grid.viewOptions')">
<Wrench class="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent align="end" class="w-max min-w-44 max-w-[calc(100vw-2rem)] gap-0 overflow-hidden rounded-md border bg-popover p-0 text-popover-foreground shadow-xl" @click.stop @keydown.stop>
<div class="border-b bg-muted/40 px-3 py-2">
<div class="text-xs font-semibold">{{ t("grid.viewOptions") }}</div>
</div>
<label class="flex cursor-pointer items-center gap-2 px-3 py-2 text-xs hover:bg-accent" :class="{ 'cursor-not-allowed opacity-60': !dataGridRef?.canToggleAllNullColumns }">
<input type="checkbox" class="h-3.5 w-3.5 shrink-0 accent-primary" :checked="!!dataGridRef?.nullColumnsHidden" :disabled="!dataGridRef?.canToggleAllNullColumns" @change="dataGridRef?.toggleAllNullColumns()" />
<span class="min-w-0 flex items-center gap-1 font-medium">
{{ t("grid.hideNullColumns") }}
<span v-if="(dataGridRef?.allNullColumnCount ?? 0) > 0" class="text-muted-foreground tabular-nums"> ({{ dataGridRef?.allNullColumnCount }}) </span>
</span>
</label>
<DataGridCopyFormatControl
:current-label="dataGridRef?.defaultCopyPreferenceLabel ?? '-'"
:current-value="dataGridRef?.defaultCopyPreference ?? ''"
:items="dataGridRef?.copyPreferenceMenuItems ?? []"
@select="dataGridRef?.setDefaultCopyPreference($event)"
@configure="openDataGridExtractorConfiguration"
/>
</PopoverContent>
</Popover>
</div>
<!-- Table view -->
<QueryLoadingState
v-if="viewMode === 'table' && loading && gridResult.columns.length === 0"
class="flex-1 min-h-0"
:label-key="documentLoadingLabelKey"
:elapsed-seconds="documentLoadingElapsedSeconds"
show-cancel
:cancel-disabled="!documentLoadExecutionId || documentLoadCancelling"
:cancelling="documentLoadCancelling"
@cancel="cancelDocumentLoad"
/>
<DataGrid
v-else-if="viewMode === 'table'"
ref="dataGridRef"
class="flex-1 min-h-0"
:result="gridResult"
:connection-id="props.connectionId"
:database="props.database"
:table-meta="props.tableMeta"
:column-layout-scope-key="documentColumnLayoutScopeKey"
context="results"
:database-type="props.databaseType"
:mongo-update-target="mongoUpdateTarget"
editable
:custom-save-handler="customSaveHandler"
:loading="loading"
:sql="documentStoreLabels.queryPreview"
:page-offset="page * pageSize"
:page-limit="pageSize"
:total-row-count="total"
:total-row-count-is-exact="totalIsExact"
:inexact-total-row-count-mode="documentStoreProvider.kind === 'mongodb' ? 'estimated' : 'at-least'"
:pagination-total-row-count="pageTotal"
:count-total-rows="countExactDocumentTotal"
@sort="onSort"
@reload="refreshDocuments"
@paginate="(offset: number, limit: number) => paginate(offset, limit)"
>
<template #search-bar="{ localFilterCount, hasLocalColumnFilters, localFilterSummaries, clearLocalFilter }: { localFilterCount: number; hasLocalColumnFilters: boolean; localFilterSummaries: LocalFilterSummary[]; clearLocalFilter: (columnIndex?: number) => void }">
<div ref="tableSearchSplitContainerRef" class="flex flex-1 min-w-0">
<div class="flex flex-1 items-start gap-1 px-2 py-0.5 min-w-0" :style="tableFindPaneStyle">
<Popover v-model:open="documentFilterBuilderOpen">
<PopoverTrigger as-child>
<button
type="button"
class="relative flex h-5 w-5 shrink-0 items-center justify-center rounded border text-[11px] font-medium transition-colors"
:class="hasLocalColumnFilters || appliedDocumentFilter ? 'border-primary/40 bg-primary/10 text-primary hover:bg-primary/15' : 'border-border/70 text-muted-foreground hover:bg-accent hover:text-foreground'"
@click="ensureDocumentFilterRule"
>
<Filter class="h-3 w-3" />
<span v-if="localFilterCount + documentStructuredFilterCount" class="absolute -right-1 -top-1 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-primary px-1 text-[9px] leading-none text-primary-foreground">
{{ localFilterCount + documentStructuredFilterCount }}
</span>
</button>
</PopoverTrigger>
<PopoverContent align="start" class="max-w-[calc(100vw-32px)] gap-3 p-3" :class="documentStoreProvider.kind === 'elasticsearch' ? 'w-[680px]' : 'w-[360px]'">
<div class="flex items-center justify-between gap-3">
<div class="text-xs font-medium text-foreground">{{ t("grid.filter") }}</div>
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" @click="addDocumentFilterRule">
<Plus class="mr-1 h-3.5 w-3.5" />
{{ t("grid.filterBuilderAddRule") }}
</Button>
</div>
<div v-if="hasLocalColumnFilters" class="space-y-2 rounded-md border border-primary/20 bg-primary/5 px-2.5 py-2">
<div class="flex items-center justify-between gap-3">
<div class="flex min-w-0 items-center gap-2 text-xs font-medium text-primary">
<Filter class="h-3.5 w-3.5 shrink-0" />
<span class="truncate">{{ t("grid.localFiltersActive", { count: localFilterCount }) }}</span>
</div>
<Button variant="ghost" size="sm" class="h-7 shrink-0 px-2 text-xs" @click="clearLocalFilter()">
<X class="mr-1 h-3.5 w-3.5" />
{{ t("grid.clearLocalFiltersShort") }}
</Button>
</div>
<div class="space-y-1">
<div v-for="summary in localFilterSummaries" :key="summary.columnIndex" class="grid grid-cols-[minmax(0,0.9fr)_minmax(0,1.6fr)_auto] items-center gap-2 rounded border border-primary/10 bg-background/70 px-2 py-1 text-xs">
<span class="truncate font-medium text-foreground" :title="summary.columnName">
{{ summary.columnName }}
</span>
<span class="min-w-0 truncate font-mono text-muted-foreground">
<template v-for="(value, valueIndex) in summary.values" :key="valueIndex">
<span v-if="valueIndex > 0">, </span>
<span>{{ value }}</span>
</template>
<span v-if="summary.hiddenValueCount">
{{ t("grid.localFilterMoreValues", { count: summary.hiddenValueCount }) }}
</span>
</span>
<Button variant="ghost" size="icon" class="h-6 w-6 text-muted-foreground hover:text-destructive" :title="t('grid.clearFilter')" @click="clearLocalFilter(summary.columnIndex)">
<X class="h-3.5 w-3.5" />
</Button>
</div>
</div>
</div>
<div v-if="documentFilterRules.length" class="space-y-2">
<template v-for="(rule, index) in documentFilterRules" :key="rule.id">
<div v-if="index > 0 && documentStoreProvider.kind !== 'elasticsearch'" class="flex justify-center">
<Button
variant="ghost"
size="sm"
class="h-6 px-2 text-[11px] font-medium text-muted-foreground hover:text-foreground"
@click="
updateDocumentFilterRule(rule.id, {
conjunction: rule.conjunction === 'AND' ? 'OR' : 'AND',
})
"
>
{{ rule.conjunction }}
</Button>
</div>
<div class="grid items-center gap-1.5" :class="documentStoreProvider.kind === 'elasticsearch' ? 'grid-cols-[minmax(0,0.75fr)_minmax(0,1.2fr)_minmax(0,1fr)_minmax(0,1fr)_auto]' : 'grid-cols-[minmax(0,1fr)_minmax(0,0.95fr)_minmax(0,1fr)_auto]'">
<Select v-if="documentStoreProvider.kind === 'elasticsearch'" :model-value="rule.elasticsearchClause || 'filter'" @update:model-value="(value: any) => updateDocumentFilterRule(rule.id, { elasticsearchClause: value as ElasticsearchBoolClause })">
<SelectTrigger class="h-8 w-full min-w-0 overflow-hidden text-xs [&_[data-slot=select-value]]:min-w-0 [&_[data-slot=select-value]]:truncate">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem v-for="clause in elasticsearchBoolClauseOptions" :key="clause" :value="clause">
{{ clause }}
</SelectItem>
</SelectContent>
</Select>
<Popover :open="!!documentFilterFieldPopoverOpen[rule.id]" @update:open="(open) => setDocumentFilterFieldPopoverOpen(rule.id, open)">
<PopoverTrigger as-child>
<button type="button" class="flex h-8 w-full min-w-0 items-center justify-between gap-1 rounded-md border bg-background px-2 text-left text-xs hover:bg-accent focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring">
<span class="min-w-0 truncate font-mono" :title="documentFilterFieldLabel(rule.fieldName)">{{ documentFilterFieldLabel(rule.fieldName) || t("grid.filterBuilderColumn") }}</span>
<ChevronDown class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</button>
</PopoverTrigger>
<PopoverContent align="start" class="w-72 max-w-[calc(100vw-32px)] gap-0 overflow-hidden rounded-md border bg-popover p-0 text-popover-foreground shadow-lg" @click.stop @keydown.stop>
<div class="border-b bg-muted/40 px-2 py-1.5 text-xs font-medium text-foreground">{{ t("grid.filterBuilderColumn") }}</div>
<div class="relative border-b p-2">
<Search class="pointer-events-none absolute left-4 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input :id="documentFilterFieldSearchInputId(rule.id)" v-model="documentFilterFieldSearch[rule.id]" autofocus class="h-7 pl-7 text-xs" :placeholder="t('grid.filterBuilderSearchColumns')" />
</div>
<div class="max-h-72 overflow-auto py-1">
<div v-for="field in documentFilterFieldRowsForRule(rule.id)" :key="field.path" class="flex items-center gap-1 px-1.5">
<button
type="button"
class="flex h-7 w-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
:class="{ invisible: field.children.length === 0 || documentFilterFieldSearchActive(rule.id) }"
:style="{ marginLeft: `${field.depth * 14}px` }"
@click.stop="toggleDocumentFilterFieldExpanded(field.path)"
>
<ChevronRight v-if="!expandedDocumentFilterFieldPaths.has(field.path)" class="h-3.5 w-3.5" />
<ChevronDown v-else class="h-3.5 w-3.5" />
</button>
<button
type="button"
class="flex h-7 min-w-0 flex-1 items-center gap-1.5 rounded px-1.5 text-left text-xs hover:bg-accent disabled:cursor-default disabled:text-muted-foreground disabled:hover:bg-transparent"
:class="rule.fieldName === field.path ? 'bg-accent text-foreground' : ''"
:disabled="!field.selectable"
@click="selectDocumentFilterField(rule.id, field.path)"
>
<span class="min-w-0 flex-1 truncate font-mono" :title="documentStoreProvider.kind === 'elasticsearch' ? field.path : field.displayPath">
{{ documentFilterFieldSearchActive(rule.id) ? (documentStoreProvider.kind === "elasticsearch" ? field.path : field.displayPath) : field.label }}
</span>
<span v-if="documentStoreProvider.kind === 'elasticsearch' && field.selectable && elasticsearchFieldTypes.get(field.path)" class="shrink-0 text-[10px] text-muted-foreground"> ({{ elasticsearchFieldTypes.get(field.path) }}) </span>
<span v-else-if="documentStoreProvider.kind !== 'elasticsearch' && field.kind !== 'scalar'" class="shrink-0 rounded border px-1 py-0 text-[10px] leading-4 text-muted-foreground">
{{ documentFilterFieldKindLabel(field.kind) }}
</span>
</button>
</div>
<div v-if="documentFilterFieldRowsForRule(rule.id).length === 0" class="px-3 py-6 text-center text-xs text-muted-foreground">
{{ t("grid.noSearchResults") }}
</div>
</div>
</PopoverContent>
</Popover>
<Select v-if="documentStoreProvider.kind === 'elasticsearch'" :model-value="rule.elasticsearchQueryType || elasticsearchRuleQueryTypes(rule)[0]" @update:model-value="(value: any) => updateDocumentFilterRule(rule.id, { elasticsearchQueryType: value as ElasticsearchQueryType })">
<SelectTrigger class="h-8 w-full min-w-0 overflow-hidden text-xs [&_[data-slot=select-value]]:min-w-0 [&_[data-slot=select-value]]:truncate">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem v-for="queryType in elasticsearchRuleQueryTypes(rule)" :key="queryType" :value="queryType">
{{ elasticsearchQueryTypeLabel(queryType) }}
</SelectItem>
</SelectContent>
</Select>
<Select v-else :model-value="rule.mode" @update:model-value="(value: any) => updateDocumentFilterRule(rule.id, { mode: value as DocumentFilterMode })">
<SelectTrigger class="h-8 w-full min-w-0 overflow-hidden text-xs [&_[data-slot=select-value]]:min-w-0 [&_[data-slot=select-value]]:truncate">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem v-for="option in documentFilterModeOptions" :key="option.value" :value="option.value">
{{ t(option.labelKey) }}
</SelectItem>
</SelectContent>
</Select>
<Input
v-if="documentStoreProvider.kind === 'elasticsearch' ? elasticsearchQueryTypeNeedsValue(rule.elasticsearchQueryType) : documentFilterModeNeedsValue(rule.mode)"
:model-value="rule.rawValue"
class="h-8 min-w-0 text-xs"
:placeholder="t('grid.filterBuilderValue')"
@update:model-value="(value) => updateDocumentFilterRule(rule.id, { rawValue: String(value ?? '') })"
@keydown.enter.prevent="applyDocumentStructuredFilters"
/>
<div v-else class="flex h-8 min-w-0 items-center overflow-hidden rounded-md border border-dashed px-2 text-xs text-muted-foreground">
<span class="truncate">{{ t("grid.filterBuilderNoValue") }}</span>
</div>
<Button variant="ghost" size="icon" class="h-8 w-8 shrink-0 text-muted-foreground hover:text-destructive" :disabled="documentFilterRules.length === 1" @click="removeDocumentFilterRule(rule.id)">
<Trash2 class="h-3.5 w-3.5" />
</Button>
</div>
</template>
</div>
<div v-else class="rounded-md border border-dashed px-3 py-4 text-center text-xs text-muted-foreground">
{{ t("grid.filterBuilderEmpty") }}
</div>
<div class="flex items-center justify-between gap-2 pt-1">
<Button variant="ghost" size="sm" class="h-8 px-2 text-xs" @click="clearDocumentFilters(clearLocalFilter)">
{{ t("grid.clearFilter") }}
</Button>
<div class="flex items-center gap-2">
<Button variant="ghost" size="sm" class="h-8 px-2 text-xs" @click="resetDocumentFilterBuilder">
{{ t("grid.resetFilterBuilder") }}
</Button>
<Button size="sm" class="h-8 px-3 text-xs" @click="applyDocumentStructuredFilters">
{{ t("grid.applyFilter") }}
</Button>
</div>
</div>
</PopoverContent>
</Popover>
<span class="text-blue-600 dark:text-blue-400 text-xs font-medium select-none shrink-0 pt-0.5">{{ documentStoreProvider.filterInputLabel }}</span>
<textarea
ref="filterInputRef"
v-model="filterInput"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
rows="1"
class="document-query-input flex-1 min-w-0 text-xs bg-transparent outline-none placeholder:text-muted-foreground/60 font-mono"
placeholder="{}"
@keydown.enter.exact.prevent="applyFilter"
@keydown.ctrl.enter.prevent="applyFilter"
@keydown.meta.enter.prevent="applyFilter"
/>
<button v-if="filterInput.trim()" type="button" class="text-muted-foreground hover:text-foreground shrink-0 mt-0.5" title="Format JSON" aria-label="Format JSON" @click="formatFilterInput">
<Braces class="w-3 h-3" />
</button>
<button
v-if="filterInput.trim()"
type="button"
class="text-muted-foreground hover:text-foreground shrink-0 mt-0.5"
@click="
filterInput = '';
applyFilter();
"
>
<X class="w-3 h-3" />
</button>
</div>
<button
type="button"
class="group relative flex w-2 shrink-0 cursor-col-resize items-center justify-center border-l border-r border-border/80 bg-muted/15 hover:bg-primary/10 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary"
aria-label="Resize document filter and sort"
@mousedown="startTableSearchSplitResize"
@dblclick.stop="resetTableSearchSplitWidth"
>
<span class="h-5 w-px bg-border group-hover:bg-primary/60" />
</button>
<div class="flex flex-1 items-start gap-1 px-2 py-0.5 min-w-0">
<span class="text-orange-600 dark:text-orange-400 text-xs font-medium select-none shrink-0 pt-0.5">{{ documentStoreProvider.sortInputLabel }}</span>
<textarea
ref="sortInputRef"
v-model="sortInput"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
rows="1"
class="document-query-input flex-1 min-w-0 text-xs bg-transparent outline-none placeholder:text-muted-foreground/60 font-mono"
placeholder="{}"
@keydown.enter.exact.prevent="applyFilter"
@keydown.ctrl.enter.prevent="applyFilter"
@keydown.meta.enter.prevent="applyFilter"
/>
<button v-if="sortInput.trim()" type="button" class="text-muted-foreground hover:text-foreground shrink-0 mt-0.5" title="Format JSON" aria-label="Format JSON" @click="formatSortInput">
<Braces class="w-3 h-3" />
</button>
<button
v-if="sortInput.trim()"
type="button"
class="text-muted-foreground hover:text-foreground shrink-0 mt-0.5"
@click="
sortInput = '';
applyFilter();
"
>
<X class="w-3 h-3" />
</button>
</div>
</div>
</template>
</DataGrid>
<!-- Document view (split pane) -->
<Splitpanes v-else class="flex-1 min-h-0">
<!-- Document list (left) -->
<Pane :size="30" :min-size="15" :max-size="50">
<div class="h-full flex flex-col overflow-hidden">
<div class="flex-1 overflow-y-auto">
<div v-for="(doc, idx) in documents" :key="idx" class="px-3 py-1.5 border-b text-xs font-mono cursor-pointer hover:bg-accent/50 flex items-center gap-2 group" :class="{ 'bg-accent': selectedIdx === idx }" @click="selectDoc(idx)">
<span class="truncate flex-1">{{ docPreview(doc) }}</span>
<Button variant="ghost" size="icon" class="h-5 w-5 opacity-0 group-hover:opacity-100 text-destructive shrink-0" @click.stop="requestDeleteDoc(idx)">
<Trash2 class="w-3 h-3" />
</Button>
</div>
<div v-if="documents.length === 0 && !loading" class="px-3 py-8 text-center text-muted-foreground text-xs">
{{ t("mongo.emptyCollection") }}
</div>
</div>
</div>
</Pane>
<!-- Document viewer/editor (right) -->
<Pane :size="70">
<div class="h-full flex flex-col min-w-0 overflow-hidden">
<template v-if="selectedIdx !== null || isNew">
<div class="h-9 flex items-center gap-2 px-4 border-b bg-muted/30 shrink-0">
<Badge variant="secondary" class="text-xs">{{ isNew ? "New" : selectedDoc?._id }}</Badge>
<span class="flex-1" />
<Button v-if="!isEditing" variant="ghost" size="sm" class="h-6 text-xs" @click="startEdit">{{ t("mongo.edit") }}</Button>
<template v-if="isEditing">
<div class="flex items-center border rounded-md overflow-hidden mr-1">
<Button variant="ghost" size="sm" class="h-6 rounded-none px-2 text-xs" :class="{ 'bg-accent': documentEditMode === 'json' }" :disabled="isSavingDocument" @click="setDocumentEditMode('json')">{{ t("mongo.editModeJson") }}</Button>
<Button variant="ghost" size="sm" class="h-6 rounded-none px-2 text-xs" :class="{ 'bg-accent': documentEditMode === 'fields' }" :disabled="isSavingDocument" @click="setDocumentEditMode('fields')">{{ t("mongo.editModeFields") }}</Button>
</div>
<Button v-if="documentEditMode === 'fields'" variant="ghost" size="sm" class="h-6 text-xs" :disabled="isSavingDocument" @click="addField"> <Plus class="w-3 h-3 mr-1" /> {{ t("mongo.addField") }} </Button>
<Button variant="ghost" size="sm" class="h-6 text-xs" :disabled="isSavingDocument" @click="cancelEdit">{{ t("grid.discard") }}</Button>
<Button size="sm" class="h-6 text-xs" :disabled="isSavingDocument" @click="saveDoc"><Save class="w-3 h-3 mr-1" />{{ t("grid.save") }}</Button>
</template>
</div>
<div v-if="documentSearchOpen && (!isEditing || documentEditMode === 'json')" data-document-search class="flex h-9 shrink-0 items-center justify-end gap-1 border-b bg-background px-2">
<div class="relative w-56 max-w-[45%] min-w-32">
<Search class="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input ref="documentSearchInputRef" v-model="documentSearchQuery" class="h-7 pl-7 pr-2 text-xs" :placeholder="t('editor.search.find')" @keydown.enter.prevent="activateDocumentSearchMatch($event.shiftKey ? -1 : 1)" @keydown.escape.prevent="closeDocumentSearch" />
</div>
<span class="w-12 text-center text-[11px] tabular-nums text-muted-foreground">{{ documentSearchStatus }}</span>
<Button variant="ghost" size="icon" class="h-7 w-7" :title="t('editor.search.prevMatch')" :disabled="documentSearchMatches.length === 0" @click="moveDocumentSearchMatch(-1)">
<ChevronUp class="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" class="h-7 w-7" :title="t('editor.search.nextMatch')" :disabled="documentSearchMatches.length === 0" @click="moveDocumentSearchMatch(1)">
<ChevronDown class="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" class="h-7 w-7" @click="closeDocumentSearch">
<X class="h-3.5 w-3.5" />
</Button>
</div>
<div v-if="isEditing && documentEditMode === 'json' && !isNew" class="px-4 py-1.5 text-[11px] text-muted-foreground border-b bg-muted/20 shrink-0">
{{ t("mongo.jsonReplaceHint") }}
</div>
<div v-if="isEditing" class="flex-1 min-h-0 overflow-hidden bg-muted/10">
<div v-if="documentEditMode === 'json'" class="h-full min-h-0 p-2">
<RedisJsonEditor v-model="editJson" class="h-full rounded border bg-background" :save-disabled="isSavingDocument" :read-only="isSavingDocument" @save="saveDoc" />
</div>
<div v-else class="h-full overflow-auto">
<div class="json-edit min-w-fit p-5" :class="{ 'pointer-events-none opacity-60': isSavingDocument }" :style="{ ...documentFontStyle, '--mongo-key-width': editKeyWidth }" :aria-disabled="isSavingDocument ? 'true' : undefined">
<div class="json-edit-brace">{</div>
<JsonEditNode v-for="(field, idx) in editFields" :key="field.key" :node="field" parent-kind="root" :removable="!isSavingDocument && !field.readonlyValue" @remove="requestRemoveField(idx)" />
<Button variant="ghost" size="sm" class="json-edit-add" :disabled="isSavingDocument" @click="addField"> <Plus class="w-3 h-3 mr-1" /> {{ t("mongo.addField") }} </Button>
<div class="json-edit-brace">}</div>
</div>
</div>
</div>
<div v-else ref="documentViewerRef" data-document-json-viewer tabindex="-1" class="flex-1 overflow-auto bg-muted/10 outline-none">
<pre class="json-viewer min-w-fit p-5" :style="documentFontStyle" v-html="highlightedJson(editJson)" />
</div>
</template>
<div v-else class="h-full flex items-center justify-center text-muted-foreground text-sm">
{{ t("mongo.selectDocument") }}
</div>
<ErrorBanner v-if="error" :message="error" />
<DangerConfirmDialog v-model:open="showDeleteConfirm" :message="t('dangerDialog.deleteMessage')" :details="deleteDetails" :confirm-label="t('dangerDialog.deleteConfirm')" @confirm="confirmDelete" />
</div>
</Pane>
</Splitpanes>
</div>
</template>
<style scoped>
.document-query-input {
min-height: 20px;
max-height: 120px;
line-height: 1.25rem;
resize: none;
overflow-y: auto;
white-space: pre-wrap;
}
.json-viewer {
font-family: var(--dbx-editor-font-family);
font-size: var(--dbx-editor-font-size);
line-height: 1.6;
tab-size: 2;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.json-edit {
font-family: var(--dbx-editor-font-family);
font-size: var(--dbx-editor-font-size);
line-height: 1.6;
tab-size: 2;
color: var(--foreground);
white-space: pre-wrap;
}
.json-edit-brace {
color: var(--muted-foreground);
font-weight: 700;
}
.json-edit-add {
margin: 6px 0 6px 2ch;
font-family: ui-sans-serif, system-ui, sans-serif;
}
.native-document-editor {
width: 100%;
min-height: 0;
resize: none;
border: 1px solid var(--border);
border-radius: var(--dbx-radius-fixed-4);
background: var(--background);
color: var(--foreground);
padding: 14px 16px;
line-height: 1.6;
tab-size: 2;
outline: none;
white-space: pre;
overflow: auto;
}
.native-document-editor:focus {
border-color: var(--ring);
box-shadow: 0 0 0 2px color-mix(in oklab, var(--ring) 28%, transparent);
}
:deep(.json-key) {
color: #7c3aed;
font-weight: 600;
}
:deep(.json-string) {
color: #15803d;
}
:deep(.json-number) {
color: #b45309;
}
:deep(.json-boolean) {
color: #2563eb;
font-weight: 600;
}
:deep(.json-null) {
color: #64748b;
font-style: italic;
}
:global(.dark) :deep(.json-key) {
color: #c4b5fd;
}
:global(.dark) :deep(.json-string) {
color: #86efac;
}
:global(.dark) :deep(.json-number) {
color: #fbbf24;
}
:global(.dark) :deep(.json-boolean) {
color: #93c5fd;
}
:global(.dark) :deep(.json-null) {
color: #94a3b8;
}
:deep(.document-search-match) {
border-radius: 2px;
background: #fde68a;
color: inherit;
padding: 0;
}
:deep(.document-search-match-active) {
background: #f59e0b;
color: #111827;
outline: 1px solid #d97706;
}
:global(.dark) :deep(.document-search-match) {
background: #854d0e;
}
:global(.dark) :deep(.document-search-match-active) {
background: #fbbf24;
color: #111827;
}
</style>