From ffe4f748578bfbaa876a01c715e12b22dd18f52a Mon Sep 17 00:00:00 2001 From: ByteExceptionM Date: Thu, 7 May 2026 09:24:09 +0200 Subject: [PATCH] feat: Simple / Aggregation / Shell query modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a three-way mode picker in place of the old redundant db.coll header so each tab can be driven by one of: - Simple — existing filter/sort/projection/limit/export - Aggregation — single editor for an EJSON pipeline array, dispatched through a new query.aggregate IPC channel + QueryService method - Shell — mongosh-flavoured one-liner like db.coll.find({…}, {…}).sort({…}).skip(n).limit(n) or db.coll.aggregate([…]); parsed in the renderer (new shellParser with 12 tests) and dispatched to the existing find / aggregate APIs The mode picker uses an underline-indicator style flush against the toolbar body, with Run / Limit / Export on the right of the same bar. Active collection tab in the TabBar above gets a 2px primary top-border, font-medium, and a tinted db. prefix so it's clearly distinguishable. Other UX fixes bundled in: - Run morphs into a destructive Cancel (X) while a query is in flight; click soft-cancels via queryClient.cancelQueries. - Sort and Projection editors share their height — whichever's content is taller pushes the other to match (QueryEditor reports its natural content height; SimpleBody feeds max() back as both editors' minHeight). - Switching tabs no longer re-runs the query: refetchOnMount: false + staleTime: Infinity on the active query. Initial open still fetches (no cache for that key). - Run is always clickable when not loading; bumping a per-tab runEpoch on every Run guarantees a fresh fetch even when none of the mode-specific params changed. --- src/main/ipc/channels.ts | 1 + src/main/ipc/router.ts | 6 + src/main/services/QueryService.ts | 32 ++ src/preload/index.ts | 3 + .../src/features/collection/CollectionTab.tsx | 270 ++++++++--- .../src/features/collection/QueryEditor.tsx | 28 +- .../src/features/collection/QueryToolbar.tsx | 431 ++++++++++++++---- src/renderer/src/features/tabs/TabBar.tsx | 7 +- src/renderer/src/lib/api.ts | 4 + src/renderer/src/lib/queryClient.ts | 9 +- src/renderer/src/lib/shellParser.test.ts | 110 +++++ src/renderer/src/lib/shellParser.ts | 303 ++++++++++++ src/renderer/src/store/tabs.ts | 25 +- src/shared/api.ts | 3 + src/shared/schemas.ts | 9 + src/shared/types.ts | 13 + 16 files changed, 1111 insertions(+), 143 deletions(-) create mode 100644 src/renderer/src/lib/shellParser.test.ts create mode 100644 src/renderer/src/lib/shellParser.ts diff --git a/src/main/ipc/channels.ts b/src/main/ipc/channels.ts index 4f2aa80..947b0b6 100644 --- a/src/main/ipc/channels.ts +++ b/src/main/ipc/channels.ts @@ -33,6 +33,7 @@ export const Channels = { UsersDrop: 'users:drop', QueryFind: 'query:find', + QueryAggregate: 'query:aggregate', QueryCount: 'query:count', QueryReplaceOne: 'query:replaceOne', QueryInsertOne: 'query:insertOne', diff --git a/src/main/ipc/router.ts b/src/main/ipc/router.ts index 9a9ba35..d7223d4 100644 --- a/src/main/ipc/router.ts +++ b/src/main/ipc/router.ts @@ -2,6 +2,7 @@ import { ipcMain, type IpcMainInvokeEvent } from 'electron' import log from 'electron-log/main' import type { ZodType } from 'zod' import { + AggregateRequestSchema, CollectionRefSchema, ConnectionIdSchema, ConnectionInputSchema, @@ -191,6 +192,11 @@ export function registerIpcHandlers(services: Services): void { withResult(FindRequestSchema, (request) => queries.find(request)) ) + ipcMain.handle( + Channels.QueryAggregate, + withResult(AggregateRequestSchema, (request) => queries.aggregate(request)) + ) + ipcMain.handle( Channels.QueryCount, withResult(CountRequestSchema, (request) => queries.count(request)) diff --git a/src/main/services/QueryService.ts b/src/main/services/QueryService.ts index dc7dcfe..9502ca3 100644 --- a/src/main/services/QueryService.ts +++ b/src/main/services/QueryService.ts @@ -1,6 +1,8 @@ import type { Document, Filter, Sort } from 'mongodb' import { EJSON } from 'bson' import type { + AggregateRequest, + AggregateResponse, DeleteManyRequest, DeleteManyResponse, DeleteOneRequest, @@ -42,6 +44,19 @@ export class QueryService { return { documents, tookMs } } + async aggregate(req: AggregateRequest): Promise { + const client = this.connections.getClient(req.connectionId) + const coll = client.db(req.db).collection(req.coll) + + const pipeline = parsePipeline(req.pipeline) + + const startedAt = Date.now() + const docs = await coll.aggregate(pipeline).toArray() + const tookMs = Date.now() - startedAt + + return { documents: docs.map(toEnvelope), tookMs } + } + async count(req: { connectionId: string db: string @@ -155,3 +170,20 @@ function parseDocument(canonical: string): Document { } return parsed as Document } + +function parsePipeline(canonical: string): Document[] { + const parsed = EJSON.parse(canonical, { relaxed: false }) + if (!Array.isArray(parsed)) { + const e = new Error('Pipeline must be an array of stage objects') + e.name = 'ValidationError' + throw e + } + for (const stage of parsed) { + if (typeof stage !== 'object' || stage === null || Array.isArray(stage)) { + const e = new Error('Each pipeline stage must be an object') + e.name = 'ValidationError' + throw e + } + } + return parsed as Document[] +} diff --git a/src/preload/index.ts b/src/preload/index.ts index cae1b86..8426c87 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2,6 +2,8 @@ import { contextBridge, ipcRenderer } from 'electron' import type { Api } from '@shared/api' import type { Result } from '@shared/result' import type { + AggregateRequest, + AggregateResponse, CollectionInfo, CollectionStats, ConnectionConfig, @@ -70,6 +72,7 @@ const api: Api = { }, query: { find: (request: FindRequest) => invoke('query:find', request), + aggregate: (request: AggregateRequest) => invoke('query:aggregate', request), count: (request: CountRequest) => invoke('query:count', request), replaceOne: (request: ReplaceOneRequest) => invoke('query:replaceOne', request), diff --git a/src/renderer/src/features/collection/CollectionTab.tsx b/src/renderer/src/features/collection/CollectionTab.tsx index 22c97e8..82ce6a9 100644 --- a/src/renderer/src/features/collection/CollectionTab.tsx +++ b/src/renderer/src/features/collection/CollectionTab.tsx @@ -1,9 +1,10 @@ import { useMemo } from 'react' -import { useQuery } from '@tanstack/react-query' +import { useQuery, useQueryClient } from '@tanstack/react-query' import { ServerCrash } from 'lucide-react' import { api, ApiError } from '@/lib/api' import { queryKeys } from '@/lib/queryClient' import { parseMongoQuery } from '@/lib/mongoQueryLang' +import { parseShellCommand } from '@/lib/shellParser' import { useTabsStore, type CollectionTab as CollectionTabType, @@ -12,10 +13,11 @@ import { import { QueryToolbar } from './QueryToolbar' import { DocumentTable } from './DocumentTable' import { StatusBar } from './StatusBar' -import type { UuidEncoding } from '@shared/types' +import type { FindResponse, UuidEncoding } from '@shared/types' export function CollectionTab({ tab }: { tab: CollectionTabType }) { const setQuery = useTabsStore((s) => s.setQuery) + const queryClient = useQueryClient() const connectionsQuery = useQuery({ queryKey: queryKeys.connections, @@ -25,48 +27,86 @@ export function CollectionTab({ tab }: { tab: CollectionTabType }) { const uuidEncoding: UuidEncoding = connectionConfig?.uuidEncoding ?? 'default' const timezone = connectionConfig?.timezone ?? 'UTC' + // Compile all three modes' inputs up-front; the active query just picks + // its slice. parseMongoQuery rejects anything that isn't valid mongo + // shell syntax (with our $-operator extensions). const compiled = useMemo(() => { - const f = parseMongoQuery(tab.filter) - const p = parseMongoQuery(tab.projection) - const s = parseMongoQuery(tab.sort) - return { - ok: f.ok && p.ok && s.ok, - filter: f.ok ? f.ejson : null, - projection: p.ok ? p.ejson : null, - sort: s.ok ? s.ejson : null, - error: - (!f.ok && `Filter: ${f.error}`) || - (!p.ok && `Projection: ${p.error}`) || - (!s.ok && `Sort: ${s.error}`) || - null + if (tab.mode === 'simple') { + const f = parseMongoQuery(tab.filter) + const p = parseMongoQuery(tab.projection) + const s = parseMongoQuery(tab.sort) + const ok = f.ok && p.ok && s.ok + return { + mode: 'simple' as const, + ok, + filter: f.ok ? f.ejson : null, + projection: p.ok ? p.ejson : null, + sort: s.ok ? s.ejson : null, + error: + (!f.ok && `Filter: ${f.error}`) || + (!p.ok && `Projection: ${p.error}`) || + (!s.ok && `Sort: ${s.error}`) || + null + } } - }, [tab.filter, tab.projection, tab.sort]) + if (tab.mode === 'aggregation') { + const r = parseMongoQuery(tab.pipeline) + if (!r.ok) { + return { mode: 'aggregation' as const, ok: false, pipeline: null, error: r.error } + } + if (!Array.isArray(r.value)) { + return { + mode: 'aggregation' as const, + ok: false, + pipeline: null, + error: 'Pipeline must be an array' + } + } + return { mode: 'aggregation' as const, ok: true, pipeline: r.ejson, error: null } + } + // shell + const r = parseShellCommand(tab.shell) + if (!r.ok) { + return { mode: 'shell' as const, ok: false, parsed: null, error: r.error } + } + if (r.coll !== tab.coll) { + return { + mode: 'shell' as const, + ok: false, + parsed: null, + error: `Command targets "${r.coll}" but this tab is "${tab.coll}"` + } + } + return { mode: 'shell' as const, ok: true, parsed: r, error: null } + }, [tab.mode, tab.filter, tab.projection, tab.sort, tab.pipeline, tab.shell, tab.coll]) const findQuery = useQuery({ - queryKey: queryKeys.find( - tab.connectionId, - tab.db, - tab.coll, - tab.filter, - tab.projection, - tab.sort, - tab.skip, - tab.limit - ), - queryFn: () => - api.query.find({ - connectionId: tab.connectionId, - db: tab.db, - coll: tab.coll, - ...(compiled.filter ? { filter: compiled.filter } : {}), - ...(compiled.projection ? { projection: compiled.projection } : {}), - ...(compiled.sort ? { sort: compiled.sort } : {}), - skip: tab.skip, - ...(tab.limit > 0 ? { limit: tab.limit } : {}) - }), - enabled: compiled.ok + queryKey: + compiled.mode === 'simple' + ? queryKeys.find( + tab.connectionId, + tab.db, + tab.coll, + tab.filter, + tab.projection, + tab.sort, + tab.skip, + tab.limit, + tab.runEpoch + ) + : compiled.mode === 'aggregation' + ? queryKeys.aggregate(tab.connectionId, tab.db, tab.coll, tab.pipeline, tab.runEpoch) + : queryKeys.shell(tab.connectionId, tab.db, tab.coll, tab.shell, tab.runEpoch), + queryFn: () => runForMode(tab, compiled), + enabled: compiled.ok, + // Don't auto-refetch when the user just switches between tabs; the + // query only re-runs when its key changes (filter / mode / runEpoch). + refetchOnMount: false, + staleTime: Infinity }) + // Count is only meaningful for the simple mode — aggregation/shell show + // the result size in the StatusBar instead. const countQuery = useQuery({ queryKey: queryKeys.count(tab.connectionId, tab.db, tab.coll, tab.filter), queryFn: () => @@ -74,42 +114,64 @@ export function CollectionTab({ tab }: { tab: CollectionTabType }) { connectionId: tab.connectionId, db: tab.db, coll: tab.coll, - ...(compiled.filter ? { filter: compiled.filter } : {}) + ...(compiled.mode === 'simple' && compiled.filter ? { filter: compiled.filter } : {}) }), - enabled: compiled.ok + enabled: compiled.mode === 'simple' && compiled.ok }) const apply = (patch: QueryPatch) => setQuery(tab.id, patch) + const cancel = () => { + // Soft cancel via react-query: the IPC promise still settles in main + // but its result is discarded, so the UI returns to its previous state + // immediately. No queryFn signal plumbing needed. + const key = + tab.mode === 'simple' + ? queryKeys.find( + tab.connectionId, + tab.db, + tab.coll, + tab.filter, + tab.projection, + tab.sort, + tab.skip, + tab.limit, + tab.runEpoch + ) + : tab.mode === 'aggregation' + ? queryKeys.aggregate(tab.connectionId, tab.db, tab.coll, tab.pipeline, tab.runEpoch) + : queryKeys.shell(tab.connectionId, tab.db, tab.coll, tab.shell, tab.runEpoch) + void queryClient.cancelQueries({ queryKey: key }) + } + + const documents = findQuery.data?.documents ?? [] + const tookMs = findQuery.data?.tookMs + return (
-
-
- {tab.db}. - {tab.coll} -
-
- setQuery(tab.id, { skip })} /> @@ -120,7 +182,7 @@ export function CollectionTab({ tab }: { tab: CollectionTabType }) { ) : ( & { ok: true }) | null + error: string | null + } + +async function runForMode(tab: CollectionTabType, compiled: Compiled): Promise { + if (compiled.mode === 'simple') { + return api.query.find({ + connectionId: tab.connectionId, + db: tab.db, + coll: tab.coll, + ...(compiled.filter ? { filter: compiled.filter } : {}), + ...(compiled.projection ? { projection: compiled.projection } : {}), + ...(compiled.sort ? { sort: compiled.sort } : {}), + skip: tab.skip, + ...(tab.limit > 0 ? { limit: tab.limit } : {}) + }) + } + if (compiled.mode === 'aggregation') { + if (!compiled.pipeline) throw new Error('unreachable: aggregation enabled without pipeline') + return api.query.aggregate({ + connectionId: tab.connectionId, + db: tab.db, + coll: tab.coll, + pipeline: compiled.pipeline + }) + } + // shell + if (!compiled.parsed || !compiled.parsed.ok) + throw new Error('unreachable: shell enabled without parsed command') + const op = compiled.parsed.op + if (op.kind === 'aggregate') { + return api.query.aggregate({ + connectionId: tab.connectionId, + db: tab.db, + coll: tab.coll, + pipeline: op.pipeline + }) + } + if (op.kind === 'find') { + return api.query.find({ + connectionId: tab.connectionId, + db: tab.db, + coll: tab.coll, + filter: op.filter, + ...(op.projection ? { projection: op.projection } : {}), + ...(op.sort ? { sort: op.sort } : {}), + skip: op.skip ?? 0, + ...(op.limit !== null ? { limit: op.limit } : {}) + }) + } + if (op.kind === 'findOne') { + return api.query.find({ + connectionId: tab.connectionId, + db: tab.db, + coll: tab.coll, + filter: op.filter, + skip: 0, + limit: 1 + }) + } + // countDocuments — return an empty docs response; the count shows in the + // StatusBar via a one-off api.query.count call below. + const countResp = await api.query.count({ + connectionId: tab.connectionId, + db: tab.db, + coll: tab.coll, + filter: op.filter + }) + return { + documents: [ + { + id: '"_count"', + data: { _count: countResp.count }, + canonical: `{"_count":${countResp.count}}`, + hash: 'a'.repeat(64) + } + ], + tookMs: 0 + } +} + function ErrorState({ message }: { message: string }) { return (
diff --git a/src/renderer/src/features/collection/QueryEditor.tsx b/src/renderer/src/features/collection/QueryEditor.tsx index 8f10f54..1a0ed4f 100644 --- a/src/renderer/src/features/collection/QueryEditor.tsx +++ b/src/renderer/src/features/collection/QueryEditor.tsx @@ -22,6 +22,11 @@ type Props = { * formatter rejects MongoDB shell syntax (ObjectId(...) etc.). */ onFormat?: () => void + /** + * Reports the editor's natural content height (pre-clamp) on every + * change. Sister editors can use this to keep their heights in sync. + */ + onContentHeightChange?: (px: number) => void } /** @@ -42,11 +47,15 @@ export function QueryEditor({ maxHeight = 140, actions, autoFocus, - onFormat + onFormat, + onContentHeightChange }: Props) { const editorRef = useRef(null) const submitRef = useRef(onSubmit) const formatRef = useRef(onFormat) + const minHeightRef = useRef(minHeight) + const maxHeightRef = useRef(maxHeight) + const onContentHeightChangeRef = useRef(onContentHeightChange) const [contentHeight, setContentHeight] = useState(minHeight) useEffect(() => { submitRef.current = onSubmit @@ -54,6 +63,20 @@ export function QueryEditor({ useEffect(() => { formatRef.current = onFormat }, [onFormat]) + useEffect(() => { + onContentHeightChangeRef.current = onContentHeightChange + }, [onContentHeightChange]) + // Re-clamp the visible height when the bounds change from outside — + // e.g. a sister editor pushed our `minHeight` up to keep both rows the + // same height. + useEffect(() => { + minHeightRef.current = minHeight + maxHeightRef.current = maxHeight + const editor = editorRef.current + if (!editor) return + const ch = editor.getContentHeight() + setContentHeight(Math.min(Math.max(ch, minHeight), maxHeight)) + }, [minHeight, maxHeight]) const handleMount: OnMount = (editor, m) => { editorRef.current = editor @@ -75,8 +98,9 @@ export function QueryEditor({ const sync = () => { const ch = editor.getContentHeight() - const next = Math.min(Math.max(ch, minHeight), maxHeight) + const next = Math.min(Math.max(ch, minHeightRef.current), maxHeightRef.current) setContentHeight(next) + onContentHeightChangeRef.current?.(ch) } editor.onDidContentSizeChange(sync) sync() diff --git a/src/renderer/src/features/collection/QueryToolbar.tsx b/src/renderer/src/features/collection/QueryToolbar.tsx index 9737536..4a263e2 100644 --- a/src/renderer/src/features/collection/QueryToolbar.tsx +++ b/src/renderer/src/features/collection/QueryToolbar.tsx @@ -1,20 +1,30 @@ import { type FormEvent, useEffect, useMemo, useState } from 'react' -import { ArrowDownUp, Eye, Loader2, Play, Wand2, XCircle } from 'lucide-react' +import { ArrowDownUp, Eye, Play, Wand2, X, XCircle } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' import { parseMongoQuery } from '@/lib/mongoQueryLang' -import type { CollectionTab, QueryPatch } from '@/store/tabs' +import { parseShellCommand, type ShellParseResult } from '@/lib/shellParser' +import type { CollectionTab, QueryMode, QueryPatch } from '@/store/tabs' import type { DocumentEnvelope, UuidEncoding } from '@shared/types' import { ExportButton } from './ExportButton' import { QueryEditor, setDocumentFieldNames } from './QueryEditor' const EMPTY_OBJECT = '{}' -const orDefault = (s: string): string => (s.trim().length === 0 ? EMPTY_OBJECT : s) +const EMPTY_ARRAY = '[]' +const objOrDefault = (s: string): string => (s.trim().length === 0 ? EMPTY_OBJECT : s) +const arrOrDefault = (s: string): string => (s.trim().length === 0 ? EMPTY_ARRAY : s) + +const MODES: ReadonlyArray<{ id: QueryMode; label: string }> = [ + { id: 'simple', label: 'Simple' }, + { id: 'aggregation', label: 'Aggregation' }, + { id: 'shell', label: 'Shell' } +] export function QueryToolbar({ tab, onApply, + onCancel, loading, documents, uuidEncoding, @@ -25,6 +35,7 @@ export function QueryToolbar({ }: { tab: CollectionTab onApply: (patch: QueryPatch) => void + onCancel: () => void loading: boolean documents: DocumentEnvelope[] uuidEncoding: UuidEncoding @@ -33,20 +44,24 @@ export function QueryToolbar({ compiledProjection: string | null compiledSort: string | null }) { - const [filter, setFilter] = useState(() => orDefault(tab.filter)) - const [projection, setProjection] = useState(() => orDefault(tab.projection)) - const [sort, setSort] = useState(() => orDefault(tab.sort)) + const [filter, setFilter] = useState(() => objOrDefault(tab.filter)) + const [projection, setProjection] = useState(() => objOrDefault(tab.projection)) + const [sort, setSort] = useState(() => objOrDefault(tab.sort)) + const [pipeline, setPipeline] = useState(() => arrOrDefault(tab.pipeline)) + const [shell, setShell] = useState(() => tab.shell) const [limit, setLimit] = useState(tab.limit > 0 ? String(tab.limit) : '') useEffect(() => { - setFilter(orDefault(tab.filter)) - setProjection(orDefault(tab.projection)) - setSort(orDefault(tab.sort)) + setFilter(objOrDefault(tab.filter)) + setProjection(objOrDefault(tab.projection)) + setSort(objOrDefault(tab.sort)) + setPipeline(arrOrDefault(tab.pipeline)) + setShell(tab.shell) setLimit(tab.limit > 0 ? String(tab.limit) : '') - }, [tab.id, tab.filter, tab.projection, tab.sort, tab.limit]) + }, [tab.id, tab.filter, tab.projection, tab.sort, tab.pipeline, tab.shell, tab.limit]) // Cache distinct top-level field names from the most recent fetch so - // the editor can offer them as completions in any of the 3 fields. + // the editor can offer them as completions in any input. useEffect(() => { const names = new Set() for (const env of documents) { @@ -55,80 +70,229 @@ export function QueryToolbar({ setDocumentFieldNames(names) }, [documents]) - const filterStatus = useMemo(() => parseStatus(filter), [filter]) - const projectionStatus = useMemo(() => parseStatus(projection), [projection]) - const sortStatus = useMemo(() => parseStatus(sort), [sort]) + const filterStatus = useMemo(() => parseObjectStatus(filter), [filter]) + const projectionStatus = useMemo(() => parseObjectStatus(projection), [projection]) + const sortStatus = useMemo(() => parseObjectStatus(sort), [sort]) + const pipelineStatus = useMemo(() => parsePipelineStatus(pipeline), [pipeline]) + const shellStatus = useMemo(() => parseShellStatus(shell, tab.coll), [shell, tab.coll]) - const anyInvalid = + const simpleInvalid = filterStatus.kind === 'invalid' || projectionStatus.kind === 'invalid' || sortStatus.kind === 'invalid' + const modeInvalid = + tab.mode === 'simple' + ? simpleInvalid + : tab.mode === 'aggregation' + ? pipelineStatus.kind !== 'ok' + : shellStatus.kind !== 'ok' const apply = (e?: FormEvent) => { e?.preventDefault() - if (anyInvalid) return - const limitNum = Number.parseInt(limit, 10) - const nextLimit = Number.isFinite(limitNum) && limitNum > 0 ? limitNum : 0 - onApply({ filter, projection, sort, skip: 0, limit: nextLimit }) + if (modeInvalid || loading) return + // Bumping runEpoch guarantees a fresh fetch even when none of the + // mode-specific params changed (e.g. the user pressed Run twice on + // the same filter). Tab switches don't go through here. + const runEpoch = tab.runEpoch + 1 + if (tab.mode === 'simple') { + const limitNum = Number.parseInt(limit, 10) + const nextLimit = Number.isFinite(limitNum) && limitNum > 0 ? limitNum : 0 + onApply({ filter, projection, sort, skip: 0, limit: nextLimit, runEpoch }) + } else if (tab.mode === 'aggregation') { + onApply({ pipeline, skip: 0, runEpoch }) + } else { + onApply({ shell, skip: 0, runEpoch }) + } + } + + const setMode = (mode: QueryMode) => { + if (mode === tab.mode) return + onApply({ mode }) } - const formatField = (status: ParseStatus, setter: (next: string) => void): void => { + const formatObject = (status: ObjectStatus, setter: (next: string) => void): void => { if (status.kind !== 'ok') return try { - const parsed = JSON.parse(status.ejson) - setter(JSON.stringify(parsed, null, 2)) + setter(JSON.stringify(JSON.parse(status.ejson), null, 2)) } catch { - // ok-status guarantees parseable EJSON; nothing actionable here. + // ok-status guarantees parseable EJSON. + } + } + const formatPipeline = () => { + if (pipelineStatus.kind !== 'ok') return + try { + setPipeline(JSON.stringify(JSON.parse(pipelineStatus.ejson), null, 2)) + } catch { + // unreachable } } return ( -
-
-
- setFilter(orDefault(next))} + +
+ +
+ {tab.mode === 'simple' && } + {loading ? ( + + + + ) : ( + + + + )} + {tab.mode === 'simple' && ( + + )} +
+
+ +
+ {tab.mode === 'simple' && ( + apply()} - onFormat={() => formatField(filterStatus, setFilter)} - hasError={filterStatus.kind === 'invalid'} - minHeight={32} - maxHeight={180} - placeholder='filter · { _id: ObjectId("…"), createdAt: { $gt: ISODate("2024-01-01") } } ⌘/Ctrl-Enter to run' - actions={ - <> - {filterStatus.kind === 'invalid' && } - formatField(filterStatus, setFilter)} - /> - - } + formatObject={formatObject} /> -
- - -
+ + ) +} + +function ModeTabs({ current, onChange }: { current: QueryMode; onChange: (m: QueryMode) => void }) { + return ( +
+ {MODES.map((m) => { + const active = m.id === current + return ( + - - + )} + + ) + })} +
+ ) +} + +function SimpleBody({ + filter, + setFilter, + filterStatus, + sort, + setSort, + sortStatus, + projection, + setProjection, + projectionStatus, + onSubmit, + formatObject +}: { + filter: string + setFilter: (next: string) => void + filterStatus: ObjectStatus + sort: string + setSort: (next: string) => void + sortStatus: ObjectStatus + projection: string + setProjection: (next: string) => void + projectionStatus: ObjectStatus + onSubmit: () => void + formatObject: (status: ObjectStatus, setter: (next: string) => void) => void +}) { + // Sort and Projection share their height: whichever editor's content is + // taller pushes the shorter one to match. Each reports its natural + // (pre-clamp) height; the max is fed back as both editors' minHeight. + const [sortContentH, setSortContentH] = useState(30) + const [projContentH, setProjContentH] = useState(30) + const sharedRowH = Math.max(30, sortContentH, projContentH) + + return ( + <> +
+ setFilter(objOrDefault(next))} + onSubmit={onSubmit} + onFormat={() => formatObject(filterStatus, setFilter)} + hasError={filterStatus.kind === 'invalid'} + minHeight={32} + maxHeight={180} + placeholder='filter · { _id: ObjectId("…"), createdAt: { $gt: ISODate("2024-01-01") } } ⌘/Ctrl-Enter to run' + actions={ + <> + {filterStatus.kind === 'invalid' && } + formatObject(filterStatus, setFilter)} + /> + + } />
@@ -139,9 +303,11 @@ export function QueryToolbar({ placeholder="{ createdAt: -1 }" value={sort} onChange={setSort} - onSubmit={() => apply()} - onFormat={() => formatField(sortStatus, setSort)} + onSubmit={onSubmit} + onFormat={() => formatObject(sortStatus, setSort)} status={sortStatus} + minHeight={sharedRowH} + onContentHeight={setSortContentH} /> } @@ -149,12 +315,78 @@ export function QueryToolbar({ placeholder="{ name: 1, _id: 0 }" value={projection} onChange={setProjection} - onSubmit={() => apply()} - onFormat={() => formatField(projectionStatus, setProjection)} + onSubmit={onSubmit} + onFormat={() => formatObject(projectionStatus, setProjection)} status={projectionStatus} + minHeight={sharedRowH} + onContentHeight={setProjContentH} />
- + + ) +} + +function AggregationBody({ + pipeline, + setPipeline, + status, + onSubmit, + onFormat +}: { + pipeline: string + setPipeline: (next: string) => void + status: PipelineStatus + onSubmit: () => void + onFormat: () => void +}) { + return ( +
+ setPipeline(arrOrDefault(next))} + onSubmit={onSubmit} + onFormat={onFormat} + hasError={status.kind === 'invalid'} + minHeight={120} + maxHeight={400} + placeholder={'[\n { $match: { … } },\n { $group: { _id: "$type", n: { $sum: 1 } } }\n]'} + actions={ + <> + {status.kind === 'invalid' && } + + + } + /> +
+ ) +} + +function ShellBody({ + coll, + shell, + setShell, + status, + onSubmit +}: { + coll: string + shell: string + setShell: (next: string) => void + status: ShellStatus + onSubmit: () => void +}) { + return ( +
+ {status.kind === 'invalid' && }} + /> +
) } @@ -186,7 +418,9 @@ function OptionRow({ onChange, onSubmit, onFormat, - status + status, + minHeight, + onContentHeight }: { icon: React.ReactNode label: string @@ -195,7 +429,9 @@ function OptionRow({ onChange: (next: string) => void onSubmit: () => void onFormat: () => void - status: ParseStatus + status: ObjectStatus + minHeight: number + onContentHeight: (px: number) => void }) { return (
@@ -206,13 +442,14 @@ function OptionRow({
onChange(orDefault(next))} + onChange={(next) => onChange(objOrDefault(next))} onSubmit={onSubmit} onFormat={onFormat} hasError={status.kind === 'invalid'} - minHeight={30} + minHeight={minHeight} maxHeight={120} placeholder={placeholder} + onContentHeightChange={onContentHeight} actions={ <> {status.kind === 'invalid' && } @@ -257,12 +494,12 @@ function ErrorTag({ title }: { title: string }) { ) } -type ParseStatus = +type ObjectStatus = | { kind: 'empty' } | { kind: 'ok'; ejson: string } | { kind: 'invalid'; error: string } -function parseStatus(value: string): ParseStatus { +function parseObjectStatus(value: string): ObjectStatus { const trimmed = value.trim() if (trimmed.length === 0) return { kind: 'empty' } const result = parseMongoQuery(trimmed) @@ -272,3 +509,43 @@ function parseStatus(value: string): ParseStatus { } return { kind: 'ok', ejson: result.ejson } } + +type PipelineStatus = + | { kind: 'empty' } + | { kind: 'ok'; ejson: string } + | { kind: 'invalid'; error: string } + +function parsePipelineStatus(value: string): PipelineStatus { + const trimmed = value.trim() + if (trimmed.length === 0) return { kind: 'empty' } + const result = parseMongoQuery(trimmed) + if (!result.ok) return { kind: 'invalid', error: result.error } + if (!Array.isArray(result.value)) { + return { kind: 'invalid', error: 'Must be an array of stage objects' } + } + for (const stage of result.value) { + if (typeof stage !== 'object' || stage === null || Array.isArray(stage)) { + return { kind: 'invalid', error: 'Each stage must be an object' } + } + } + return { kind: 'ok', ejson: result.ejson } +} + +type ShellStatus = + | { kind: 'empty' } + | { kind: 'ok'; parsed: ShellParseResult & { ok: true } } + | { kind: 'invalid'; error: string } + +function parseShellStatus(value: string, expectedColl: string): ShellStatus { + const trimmed = value.trim() + if (trimmed.length === 0) return { kind: 'empty' } + const parsed = parseShellCommand(trimmed) + if (!parsed.ok) return { kind: 'invalid', error: parsed.error } + if (parsed.coll !== expectedColl) { + return { + kind: 'invalid', + error: `Collection mismatch: this tab is "${expectedColl}", command targets "${parsed.coll}"` + } + } + return { kind: 'ok', parsed } +} diff --git a/src/renderer/src/features/tabs/TabBar.tsx b/src/renderer/src/features/tabs/TabBar.tsx index 27d3508..b3b58d2 100644 --- a/src/renderer/src/features/tabs/TabBar.tsx +++ b/src/renderer/src/features/tabs/TabBar.tsx @@ -52,14 +52,15 @@ function TabPill({ } }} className={cn( - 'group flex shrink-0 cursor-pointer items-center gap-2 border-r px-3 text-xs', + 'group relative flex shrink-0 cursor-pointer items-center gap-2 border-r px-3 text-xs', active - ? 'border-b-0 bg-background text-foreground' + ? 'border-b-0 bg-background font-medium text-foreground' : 'text-muted-foreground hover:bg-accent/40 hover:text-foreground' )} > + {active && } - {tab.db}. + {tab.db}. {tab.coll}