Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2,097 changes: 1,062 additions & 1,035 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "mongobench",
"version": "1.4.0",
"version": "1.5.0",
"private": true,
"description": "A modern, dark-mode-first MongoDB GUI.",
"author": "ByteExceptionM",
Expand Down
6 changes: 2 additions & 4 deletions src/main/services/DatabaseService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,8 +167,7 @@ export class DatabaseService {
: undefined

const docMetrics = (status['metrics'] as Record<string, unknown> | undefined)?.['document'] as
| Record<string, unknown>
| undefined
Record<string, unknown> | undefined
const documents = {
inserted: numberOr(docMetrics?.['inserted'], 0),
returned: numberOr(docMetrics?.['returned'], 0),
Expand All @@ -186,8 +185,7 @@ export class DatabaseService {
}

const cursorMetrics = (status['metrics'] as Record<string, unknown> | undefined)?.['cursor'] as
| Record<string, unknown>
| undefined
Record<string, unknown> | undefined
const cursorOpen = cursorMetrics?.['open'] as Record<string, unknown> | undefined
const cursors = {
open: numberOr(cursorOpen?.['total'], 0),
Expand Down
3 changes: 1 addition & 2 deletions src/main/services/IndexService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,7 @@ export class IndexService {

const sizes =
((statsCursor[0]?.['storageStats'] as Record<string, unknown> | undefined)?.['indexSizes'] as
| Record<string, number>
| undefined) ?? {}
Record<string, number> | undefined) ?? {}

return raw.map((info) =>
mapIndex(info as Record<string, unknown>, sizes[info['name'] as string])
Expand Down
7 changes: 2 additions & 5 deletions src/renderer/src/features/collection/DocumentTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,7 @@ type RowMenuState = {
}

type PendingDelete =
| { kind: 'one'; envelope: DocumentEnvelope }
| { kind: 'many'; ids: string[] }
| null
{ kind: 'one'; envelope: DocumentEnvelope } | { kind: 'many'; ids: string[] } | null

export function DocumentTable({
documents,
Expand Down Expand Up @@ -779,8 +777,7 @@ function Cell({
const OID_RE = /^[a-f0-9]{24}$/i

type ExtractedRef =
| { kind: 'oid'; oid: string }
| { kind: 'dbref'; ref: string; oid: string; db?: string }
{ kind: 'oid'; oid: string } | { kind: 'dbref'; ref: string; oid: string; db?: string }

/** Returns the hex string when `value` is an EJSON ObjectId, else null. */
function objectIdOf(value: unknown): string | null {
Expand Down
158 changes: 152 additions & 6 deletions src/renderer/src/features/collection/IndexesDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type FormEvent, useEffect, useMemo, useState } from 'react'
import { type FormEvent, type KeyboardEvent, useEffect, useMemo, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import {
Expand Down Expand Up @@ -395,6 +395,15 @@ function CreateIndexForm({
const [showAdvanced, setShowAdvanced] = useState(false)
const queryClient = useQueryClient()

// Sample a handful of documents to offer their field paths as completions.
// Best-effort: an empty or failed sample just means no suggestions.
const fieldPathsQuery = useQuery({
queryKey: queryKeys.fieldPaths(connectionId, db, coll),
queryFn: () => api.query.find({ connectionId, db, coll, skip: 0, limit: 50 }),
select: (res) => collectFieldPaths(res.documents.map((d) => d.data))
})
const fieldSuggestions = fieldPathsQuery.data ?? []

const hasText = state.rows.some((r) => r.type === 'text')
const hasGeoSphere = state.rows.some((r) => r.type === '2dsphere')
const hasGeo2d = state.rows.some((r) => r.type === '2d')
Expand Down Expand Up @@ -499,12 +508,10 @@ function CreateIndexForm({
<div className="grid gap-2">
{state.rows.map((row, i) => (
<div key={i} className="flex items-center gap-2">
<Input
<FieldPathInput
value={row.field}
onChange={(e) => updateRow(i, { field: e.target.value })}
placeholder="field.path (use foo.$** for wildcard)"
spellCheck={false}
className="flex-1 font-mono"
onChange={(field) => updateRow(i, { field })}
suggestions={fieldSuggestions}
autoFocus={i === 0}
/>
<div className="w-44">
Expand Down Expand Up @@ -853,6 +860,145 @@ function EjsonField({
)
}

/**
* Field-path input with completion from sampled documents. Tab or Enter
* accepts the highlighted suggestion, arrow keys navigate, Escape closes
* the list (without closing the surrounding dialog).
*/
function FieldPathInput({
value,
onChange,
suggestions,
autoFocus
}: {
value: string
onChange: (v: string) => void
suggestions: string[]
autoFocus?: boolean
}) {
const [open, setOpen] = useState(false)
const [active, setActive] = useState(0)

const matches = useMemo(() => {
const needle = value.trim().toLowerCase()
const pool = needle ? suggestions.filter((s) => s.toLowerCase().includes(needle)) : suggestions
const sorted = needle
? [...pool].sort((a, b) => {
const aPrefix = a.toLowerCase().startsWith(needle) ? 0 : 1
const bPrefix = b.toLowerCase().startsWith(needle) ? 0 : 1
return aPrefix - bPrefix || a.localeCompare(b)
})
: pool
// Nothing left to complete once the value matches the only candidate.
if (sorted.length === 1 && sorted[0] === value.trim()) return []
return sorted.slice(0, 50)
}, [value, suggestions])

useEffect(() => {
setActive(0)
}, [value])

const accept = (s: string) => {
onChange(s)
setOpen(false)
}

const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (!open || matches.length === 0) return
if (e.key === 'ArrowDown') {
e.preventDefault()
setActive((i) => (i + 1) % matches.length)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setActive((i) => (i - 1 + matches.length) % matches.length)
} else if (e.key === 'Tab' || e.key === 'Enter') {
e.preventDefault()
accept(matches[active] ?? matches[0]!)
} else if (e.key === 'Escape') {
e.preventDefault()
e.stopPropagation()
setOpen(false)
}
}

return (
<div className="relative flex-1">
<Input
value={value}
onChange={(e) => {
onChange(e.target.value)
setOpen(true)
}}
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
onKeyDown={onKeyDown}
placeholder="field.path (use foo.$** for wildcard)"
spellCheck={false}
className="w-full font-mono"
autoFocus={autoFocus}
role="combobox"
aria-expanded={open && matches.length > 0}
aria-autocomplete="list"
/>
{open && matches.length > 0 && (
<ul
className="absolute left-0 right-0 top-full z-50 mt-1 max-h-40 overflow-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md"
role="listbox"
// Keep focus in the input so onBlur doesn't close the list
// before an option click registers.
onMouseDown={(e) => e.preventDefault()}
>
{matches.map((s, i) => (
<li
key={s}
role="option"
aria-selected={i === active}
onMouseEnter={() => setActive(i)}
onClick={() => accept(s)}
className={cn(
'cursor-pointer rounded-sm px-2 py-1 font-mono text-xs',
i === active ? 'bg-accent text-accent-foreground' : 'text-foreground'
)}
>
{s}
</li>
))}
</ul>
)}
</div>
)
}

const MAX_FIELD_PATH_DEPTH = 4

/**
* Distinct dot-notation field paths across the sampled documents, sorted.
* `data` is relaxed EJSON, so objects whose keys all start with `$`
* (e.g. { $oid }, { $date }) are type wrappers, not subdocuments. Array
* elements share their parent path, matching how indexes address them.
*/
function collectFieldPaths(docs: Array<Record<string, unknown>>): string[] {
const paths = new Set<string>()
const visit = (value: unknown, prefix: string, depth: number): void => {
if (depth > MAX_FIELD_PATH_DEPTH || value === null || typeof value !== 'object') return
if (Array.isArray(value)) {
for (const item of value) visit(item, prefix, depth)
return
}
const obj = value as Record<string, unknown>
const keys = Object.keys(obj)
if (prefix && keys.length > 0 && keys.every((k) => k.startsWith('$'))) return
for (const key of keys) {
if (key.startsWith('$')) continue
const path = prefix ? `${prefix}.${key}` : key
paths.add(path)
visit(obj[key], path, depth + 1)
}
}
for (const doc of docs) visit(doc, '', 0)
return Array.from(paths).sort((a, b) => a.localeCompare(b))
}

function validateForm(state: FormState, ttlEligible: boolean): string | null {
const filledRows = state.rows.filter((r) => r.field.trim())
if (filledRows.length === 0) return 'At least one key field is required'
Expand Down
22 changes: 10 additions & 12 deletions src/renderer/src/features/collection/QueryEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -221,18 +221,16 @@ function ensureProviderRegistered(): void {
position.lineNumber,
position.column
)
const suggestions = buildMongoCompletions().map(
(c): monaco.languages.CompletionItem => ({
label: c.label,
kind: c.kind ?? monaco.languages.CompletionItemKind.Keyword,
insertText: c.insertText,
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
documentation: { value: c.doc },
detail: c.detail,
range,
sortText: c.sortText ?? c.label
})
)
const suggestions = buildMongoCompletions().map((c): monaco.languages.CompletionItem => ({
label: c.label,
kind: c.kind ?? monaco.languages.CompletionItemKind.Keyword,
insertText: c.insertText,
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
documentation: { value: c.doc },
detail: c.detail,
range,
sortText: c.sortText ?? c.label
}))
return { suggestions }
}

Expand Down
8 changes: 2 additions & 6 deletions src/renderer/src/features/collection/QueryToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -495,9 +495,7 @@ function ErrorTag({ title }: { title: string }) {
}

type ObjectStatus =
| { kind: 'empty' }
| { kind: 'ok'; ejson: string }
| { kind: 'invalid'; error: string }
{ kind: 'empty' } | { kind: 'ok'; ejson: string } | { kind: 'invalid'; error: string }

function parseObjectStatus(value: string): ObjectStatus {
const trimmed = value.trim()
Expand All @@ -511,9 +509,7 @@ function parseObjectStatus(value: string): ObjectStatus {
}

type PipelineStatus =
| { kind: 'empty' }
| { kind: 'ok'; ejson: string }
| { kind: 'invalid'; error: string }
{ kind: 'empty' } | { kind: 'ok'; ejson: string } | { kind: 'invalid'; error: string }

function parsePipelineStatus(value: string): PipelineStatus {
const trimmed = value.trim()
Expand Down
Loading
Loading