From 1235c2d57be8e49c8f9e2d3a049fea07cb19aeac Mon Sep 17 00:00:00 2001 From: AKHIL Date: Tue, 4 Aug 2026 20:47:07 +0530 Subject: [PATCH 01/26] feat(data-explorer): add unified connection vault to local router Co-Authored-By: Claude Opus 5 (1M context) --- apps/desktop/src-tauri/src/router/mod.rs | 35 ++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/apps/desktop/src-tauri/src/router/mod.rs b/apps/desktop/src-tauri/src/router/mod.rs index 672d1e1f..ae3f82aa 100644 --- a/apps/desktop/src-tauri/src/router/mod.rs +++ b/apps/desktop/src-tauri/src/router/mod.rs @@ -105,6 +105,7 @@ pub fn route(state: &AppState, method: &str, full_path: &str, body: Option<&str> ("/redis-commander/connections", "redis_connections"), ("/nosql/connections", "nosql_connections"), ("/s3-drive/connections", "s3_connections"), + ("/data-explorer/connections", "data_explorer_connections"), ] { if rel.starts_with(prefix) { // rest starts at "/connections..." @@ -499,4 +500,38 @@ mod tests { assert_eq!(r.status, 200); assert_eq!(body_json(&r).as_array().unwrap().len(), 1); } + + #[test] + fn data_explorer_connection_vault_crud() { + let state = AppState::in_memory(); + let base = "/api/v1/data-explorer/connections"; + + // Create — sourceId is opaque to the store, any string round-trips. + let payload = r#"{"sourceId":"redis","name":"cache","encryptedData":"AAA","iv":"BBB"}"#; + let r = route(&state, "POST", base, Some(payload)).unwrap(); + assert_eq!(r.status, 200, "{}", r.body); + let created = body_json(&r); + let id = created["id"].as_str().unwrap().to_string(); + assert_eq!(created["sourceId"], "redis"); + assert_eq!(created["encryptedData"], "AAA"); + + // List + let r = route(&state, "GET", base, None).unwrap(); + assert_eq!(body_json(&r).as_array().unwrap().len(), 1); + + // Patch advances updatedAt and merges fields + let r = route(&state, "PATCH", &format!("{base}/{id}"), Some(r#"{"name":"cache-prod"}"#)).unwrap(); + assert_eq!(body_json(&r)["name"], "cache-prod"); + assert_eq!(body_json(&r)["sourceId"], "redis"); + + // Touch bumps lastUsedAt only + let r = route(&state, "POST", &format!("{base}/{id}/touch"), None).unwrap(); + assert_eq!(r.status, 200); + + // Delete tombstones + let r = route(&state, "DELETE", &format!("{base}/{id}"), None).unwrap(); + assert_eq!(r.status, 204); + let r = route(&state, "GET", base, None).unwrap(); + assert_eq!(body_json(&r).as_array().unwrap().len(), 0); + } } From 8f8af62a45ef42a46a3f68ea07094fbfb94d1ee8 Mon Sep 17 00:00:00 2001 From: AKHIL Date: Tue, 4 Aug 2026 20:51:05 +0530 Subject: [PATCH 02/26] feat(data-explorer): add source adapter contract and registry Co-Authored-By: Claude Opus 5 (1M context) --- .../data-explorer/__tests__/sources.test.ts | 32 +++++ .../src/components/data-explorer/sources.ts | 21 ++++ .../src/components/data-explorer/types.ts | 110 ++++++++++++++++++ 3 files changed, 163 insertions(+) create mode 100644 apps/desktop-ui/src/components/data-explorer/__tests__/sources.test.ts create mode 100644 apps/desktop-ui/src/components/data-explorer/sources.ts create mode 100644 apps/desktop-ui/src/components/data-explorer/types.ts diff --git a/apps/desktop-ui/src/components/data-explorer/__tests__/sources.test.ts b/apps/desktop-ui/src/components/data-explorer/__tests__/sources.test.ts new file mode 100644 index 00000000..1d745832 --- /dev/null +++ b/apps/desktop-ui/src/components/data-explorer/__tests__/sources.test.ts @@ -0,0 +1,32 @@ +import { SOURCES, SOURCE_ORDER, getAdapter } from "../sources"; + +describe("source registry", () => { + it("keys every adapter by its own id", () => { + for (const [key, adapter] of Object.entries(SOURCES)) { + expect(adapter.id).toBe(key); + } + }); + + it("orders exactly the registered sources, no more, no less", () => { + expect([...SOURCE_ORDER].sort()).toEqual(Object.keys(SOURCES).sort()); + }); + + it("gives every adapter the full contract", () => { + for (const adapter of Object.values(SOURCES)) { + expect(typeof adapter.label).toBe("string"); + expect(adapter.label.length).toBeGreaterThan(0); + expect(typeof adapter.accent).toBe("string"); + expect(typeof adapter.blankConfig).toBe("function"); + expect(typeof adapter.validate).toBe("function"); + expect(typeof adapter.testConnection).toBe("function"); + expect(adapter.icon).toBeDefined(); + expect(adapter.ConnectionForm).toBeDefined(); + expect(adapter.SidebarTree).toBeDefined(); + expect(adapter.Pane).toBeDefined(); + } + }); + + it("returns null for an unknown source instead of throwing", () => { + expect(getAdapter("not-a-real-source")).toBeNull(); + }); +}); diff --git a/apps/desktop-ui/src/components/data-explorer/sources.ts b/apps/desktop-ui/src/components/data-explorer/sources.ts new file mode 100644 index 00000000..b77f6bfd --- /dev/null +++ b/apps/desktop-ui/src/components/data-explorer/sources.ts @@ -0,0 +1,21 @@ +import type { SourceAdapter, SourceId } from "./types"; + +/** + * The one registry. Adding a data source type means writing an adapter file + * and adding it here — nothing else in the shell, sidebar, tab bar, store, + * Rust router, or the six global tool registries changes. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const SOURCES: Record> = {}; + +/** Display order in the sidebar and the source picker. */ +export const SOURCE_ORDER: SourceId[] = []; + +/** + * Resolve an adapter. Returns null for an unknown id — a connection saved by a + * newer build must degrade to an "unsupported source" row, not crash the list. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function getAdapter(sourceId: SourceId): SourceAdapter | null { + return SOURCES[sourceId] ?? null; +} diff --git a/apps/desktop-ui/src/components/data-explorer/types.ts b/apps/desktop-ui/src/components/data-explorer/types.ts new file mode 100644 index 00000000..aa359341 --- /dev/null +++ b/apps/desktop-ui/src/components/data-explorer/types.ts @@ -0,0 +1,110 @@ +import type React from "react"; + +/** + * Opaque identifier for a data source type, persisted on every connection. + * Never renamed once shipped — stored connections carry it. + */ +export type SourceId = string; + +export interface UnifiedConnection { + id: string; + userId: string; + sourceId: SourceId; + name: string; + /** Optional sidebar grouping; empty means ungrouped. */ + folder?: string; + /** Environment accent colour (hex). Not sensitive. */ + color?: string | null; + /** Blocks write operations in the UI when true. */ + readOnly?: boolean; + /** AES-GCM ciphertext of the JSON-serialised adapter config. */ + encryptedData: string; + iv: string; + /** Decrypted client-side after fetch. Never sent to the store. */ + config?: unknown; + createdAt: number; + lastUsedAt: number; + /** Content-edit clock (sync LWW); store-managed. */ + updatedAt?: number; +} + +export interface UnifiedTab { + id: string; + connectionId: string; + sourceId: SourceId; + /** Tab strip label — e.g. a collection name or "db 0". */ + title: string; + /** Tooltip second line — e.g. "prod-atlas › orders". */ + subtitle?: string; + connectionColor?: string | null; + readOnly?: boolean; + /** Adapter-owned. The shell stores and passes it through, never inspects it. */ + state: unknown; +} + +export interface OpenTabRequest { + /** + * Stable per (connection, leaf). The shell builds the tab id as + * `${connection.id}:${key}`, so reopening the same leaf focuses the + * existing tab instead of duplicating it. + */ + key: string; + title: string; + subtitle?: string; + state: TabState; +} + +export interface ConnectionFormValues { + name: string; + folder?: string; + color?: string | null; + readOnly?: boolean; + config: Config; +} + +export interface ConnectionFormProps { + /** Existing values when editing; adapter defaults when creating. */ + initial: ConnectionFormValues; + /** True while the shell is persisting. Disable the submit button. */ + saving: boolean; + /** Persist or test-connection failure, already sanitised. Render inline. */ + error: string | null; + onSubmit: (values: ConnectionFormValues) => void; + onCancel: () => void; +} + +export interface SidebarTreeProps { + connection: UnifiedConnection; + config: Config; + openTab: (req: OpenTabRequest) => void; + /** Call after a destructive tree action so the shell refetches connections. */ + onConnectionsChanged: () => void; +} + +export interface PaneProps { + connection: UnifiedConnection; + config: Config; + tab: UnifiedTab; + state: TabState; + setState: (updater: (prev: TabState) => TabState) => void; +} + +export interface SourceAdapter { + /** Must equal this adapter's key in SOURCES. */ + id: SourceId; + /** Proper noun — never translated. */ + label: string; + icon: React.ComponentType<{ className?: string }>; + /** Tailwind text colour class for the sidebar group and tab glyph. */ + accent: string; + + blankConfig: () => Config; + /** Returns a user-facing error message, or null when the config is valid. */ + validate: (config: Config) => string | null; + /** Throws with a user-facing, credential-free message on failure. */ + testConnection: (config: Config) => Promise; + + ConnectionForm: React.ComponentType>; + SidebarTree: React.ComponentType>; + Pane: React.ComponentType>; +} From 20f978a4fd7940f5b1881d6a950cc6b3f08c374b Mon Sep 17 00:00:00 2001 From: AKHIL Date: Tue, 4 Aug 2026 20:59:11 +0530 Subject: [PATCH 03/26] feat(data-explorer): add unified connection store client Co-Authored-By: Claude Opus 5 (1M context) --- .../__tests__/connection-service.test.ts | 93 ++++++++++++++ .../data-explorer/connection-service.ts | 113 ++++++++++++++++++ 2 files changed, 206 insertions(+) create mode 100644 apps/desktop-ui/src/components/data-explorer/__tests__/connection-service.test.ts create mode 100644 apps/desktop-ui/src/components/data-explorer/connection-service.ts diff --git a/apps/desktop-ui/src/components/data-explorer/__tests__/connection-service.test.ts b/apps/desktop-ui/src/components/data-explorer/__tests__/connection-service.test.ts new file mode 100644 index 00000000..24ee268a --- /dev/null +++ b/apps/desktop-ui/src/components/data-explorer/__tests__/connection-service.test.ts @@ -0,0 +1,93 @@ +import { webcrypto } from "node:crypto"; + +// encryption.ts reaches for `window.crypto` / `window.btoa` / `window.atob`, but +// jest-environment-node exposes none of the `window` global. Same shim as +// src/lib/__tests__/content-envelope.test.ts — polyfill from node:crypto before +// the module under test loads. Do not mock the crypto primitives themselves. +if (typeof globalThis.crypto === "undefined") { + Object.defineProperty(globalThis, "crypto", { value: webcrypto, writable: true, configurable: true }); +} +const g = globalThis as unknown as { window?: Record; btoa: unknown; atob: unknown }; +g.window = g.window ?? {}; +g.window.crypto = globalThis.crypto; +g.window.btoa = g.btoa; +g.window.atob = g.atob; + +import { decodeConnection } from "../connection-service"; +import { encryptData } from "@/lib/encryption"; + +async function testKey(): Promise { + return crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, [ + "encrypt", + "decrypt", + ]); +} + +describe("decodeConnection", () => { + it("round-trips a Mongo config", async () => { + const key = await testKey(); + const config = { connectionString: "mongodb://localhost:27017", dbType: "mongodb" }; + const { encrypted, iv } = await encryptData(key, JSON.stringify(config)); + + const decoded = await decodeConnection( + { + id: "c1", + userId: "desktop-local", + sourceId: "mongodb", + name: "local", + encryptedData: encrypted, + iv, + createdAt: 1, + lastUsedAt: 1, + }, + key + ); + + expect(decoded.config).toEqual(config); + expect(decoded.sourceId).toBe("mongodb"); + }); + + it("round-trips a Redis config", async () => { + const key = await testKey(); + const config = { redisUrl: "redis://localhost:6379" }; + const { encrypted, iv } = await encryptData(key, JSON.stringify(config)); + + const decoded = await decodeConnection( + { + id: "c2", + userId: "desktop-local", + sourceId: "redis", + name: "cache", + encryptedData: encrypted, + iv, + createdAt: 1, + lastUsedAt: 1, + }, + key + ); + + expect(decoded.config).toEqual(config); + }); + + it("rejects a payload encrypted with a different key", async () => { + const keyA = await testKey(); + const keyB = await testKey(); + const { encrypted, iv } = await encryptData(keyA, JSON.stringify({ redisUrl: "x" })); + + await expect( + decodeConnection( + { + id: "c3", + userId: "desktop-local", + sourceId: "redis", + name: "bad", + encryptedData: encrypted, + iv, + createdAt: 1, + lastUsedAt: 1, + }, + keyB + ) + ).rejects.toBeDefined(); + }); +}); diff --git a/apps/desktop-ui/src/components/data-explorer/connection-service.ts b/apps/desktop-ui/src/components/data-explorer/connection-service.ts new file mode 100644 index 00000000..15b01030 --- /dev/null +++ b/apps/desktop-ui/src/components/data-explorer/connection-service.ts @@ -0,0 +1,113 @@ +import { encryptData, decryptData } from "@/lib/encryption"; +import { proxyJsonAuthed } from "@/lib/backend-auth"; +import { toast } from "sonner"; +import type { ConnectionFormValues, SourceId, UnifiedConnection } from "./types"; + +const BACKEND_BASE_URL: string = + process.env.NEXT_PUBLIC_FASTAPI_BASE_URL || + process.env.NEXT_PUBLIC_BACKEND_BASE_URL || + "http://localhost:8000"; + +const BASE_PATH = "/api/v1/data-explorer/connections"; + +/** Raw store row — the decrypted `config` is never present on the wire. */ +export type UnifiedConnectionRaw = Omit; + +async function proxyRequest(method: string, path: string, body?: unknown): Promise { + const { status, data } = await proxyJsonAuthed(BACKEND_BASE_URL, method, path, body); + if (status < 200 || status >= 300) { + const err = data as Record | null; + throw new Error( + (typeof err?.detail === "string" ? err.detail : null) || + (typeof err?.error === "string" ? err.error : null) || + `Request failed (${status})` + ); + } + return data as T; +} + +/** Decrypt one row's config blob. Throws when the master key does not match. */ +export async function decodeConnection( + raw: UnifiedConnectionRaw, + encryptionKey: CryptoKey +): Promise { + const json = await decryptData(encryptionKey, raw.encryptedData, raw.iv); + return { ...raw, config: JSON.parse(json) as unknown }; +} + +export async function listConnections(encryptionKey: CryptoKey): Promise { + const rawAll = (await proxyRequest("GET", BASE_PATH)) ?? []; + // Drop malformed rows — a row missing id/encryptedData/iv would crash the + // list render that keys off conn.id. + const raw = rawAll.filter( + (c): c is UnifiedConnectionRaw => + !!c && + typeof c === "object" && + typeof c.id === "string" && + typeof c.sourceId === "string" && + !!c.encryptedData && + !!c.iv + ); + + const results = await Promise.allSettled(raw.map((c) => decodeConnection(c, encryptionKey))); + + const connections: UnifiedConnection[] = []; + let failedCount = 0; + for (const result of results) { + if (result.status === "fulfilled") connections.push(result.value); + else failedCount++; + } + + if (failedCount > 0) { + toast.error( + `${failedCount} connection${failedCount > 1 ? "s" : ""} could not be decrypted. ` + + "Delete and re-add them." + ); + } + + return connections; +} + +export async function saveConnection( + sourceId: SourceId, + values: ConnectionFormValues, + encryptionKey: CryptoKey +): Promise { + const { encrypted, iv } = await encryptData(encryptionKey, JSON.stringify(values.config)); + const created = await proxyRequest("POST", BASE_PATH, { + sourceId, + name: values.name, + folder: values.folder ?? "", + color: values.color ?? null, + readOnly: values.readOnly ?? false, + encryptedData: encrypted, + iv, + }); + return { ...created, config: values.config }; +} + +export async function updateConnection( + id: string, + patch: Partial>, + encryptionKey: CryptoKey +): Promise { + const body: Record = {}; + if (patch.name !== undefined) body.name = patch.name; + if (patch.folder !== undefined) body.folder = patch.folder; + if (patch.color !== undefined) body.color = patch.color; + if (patch.readOnly !== undefined) body.readOnly = patch.readOnly; + if (patch.config !== undefined) { + const { encrypted, iv } = await encryptData(encryptionKey, JSON.stringify(patch.config)); + body.encryptedData = encrypted; + body.iv = iv; + } + await proxyRequest("PATCH", `${BASE_PATH}/${id}`, body); +} + +export async function deleteConnection(id: string): Promise { + await proxyRequest("DELETE", `${BASE_PATH}/${id}`); +} + +export async function touchConnection(id: string): Promise { + await proxyRequest("POST", `${BASE_PATH}/${id}/touch`); +} From 8de27422c1dc4df51fc6ab034a2ef23067adc549 Mon Sep 17 00:00:00 2001 From: AKHIL Date: Tue, 4 Aug 2026 21:07:08 +0530 Subject: [PATCH 04/26] feat(data-explorer): register route and tool metadata Co-Authored-By: Claude Opus 5 (1M context) --- apps/desktop-ui/messages/en.json | 40 +++++++++++++++++++ .../src/app/app/data-explorer/layout.tsx | 7 ++++ .../src/app/app/data-explorer/page.tsx | 5 +++ .../components/sidebar/data/sidebar-data.ts | 6 +++ apps/desktop-ui/src/lib/metadata.ts | 6 +++ apps/desktop-ui/src/lib/route-config.ts | 1 + apps/desktop-ui/src/lib/tab-registry.tsx | 1 + apps/desktop-ui/src/lib/tool-categories.ts | 1 + apps/desktop-ui/src/lib/tool-i18n.ts | 1 + 9 files changed, 68 insertions(+) create mode 100644 apps/desktop-ui/src/app/app/data-explorer/layout.tsx create mode 100644 apps/desktop-ui/src/app/app/data-explorer/page.tsx diff --git a/apps/desktop-ui/messages/en.json b/apps/desktop-ui/messages/en.json index 141a11f6..5618417a 100644 --- a/apps/desktop-ui/messages/en.json +++ b/apps/desktop-ui/messages/en.json @@ -53,6 +53,7 @@ "apiClient": "API Client", "httpStatusCodes": "HTTP status codes", "nosqlExplorer": "Database Explorer", + "dataExplorer": "Data Explorer", "cssGradientBuilder": "CSS Gradient Builder", "gitignoreGenerator": ".gitignore Generator", "csvExcelJson": "CSV / Excel ↔ JSON", @@ -483,6 +484,10 @@ "title": "Database Explorer", "description": "Explore MongoDB and SQL databases." }, + "dataExplorer": { + "title": "Data Explorer", + "description": "One workspace for MongoDB and Redis. Browse, query, and edit with an encrypted credential store." + }, "emailValidator": { "title": "Email Validator", "description": "Verify and validate email addresses." @@ -1874,6 +1879,41 @@ "exportHtml": "HTML report" } }, + "DataExplorer": { + "title": "Data Explorer", + "emptyTitle": "No connections yet", + "emptyBody": "Add a MongoDB or Redis connection to start browsing. Credentials are encrypted on your device.", + "addConnection": "New connection", + "importLegacy": "Import from existing tools", + "searchConnections": "Search connections", + "unsupportedSource": "Unsupported source", + "unsupportedSourceHint": "This connection was created by a newer version of the app.", + "closeAll": "Close all", + "noTabsTitle": "Nothing open", + "noTabsBody": "Pick a connection in the sidebar to open a tab.", + "connectionDialog": { + "createTitle": "New connection", + "editTitle": "Edit connection", + "pickSource": "Data source", + "name": "Name", + "namePlaceholder": "My connection", + "folder": "Folder", + "folderPlaceholder": "Optional group", + "color": "Colour", + "readOnly": "Read-only", + "readOnlyHint": "Blocks every write operation for this connection.", + "test": "Test connection", + "testOk": "Connected", + "save": "Save", + "cancel": "Cancel" + }, + "toast": { + "saved": "Connection saved", + "deleted": "Connection deleted", + "saveFailed": "Could not save the connection", + "deleteFailed": "Could not delete the connection" + } + }, "ApiClient": { "defaults": { "newRequest": "New Request", diff --git a/apps/desktop-ui/src/app/app/data-explorer/layout.tsx b/apps/desktop-ui/src/app/app/data-explorer/layout.tsx new file mode 100644 index 00000000..9ca91ea5 --- /dev/null +++ b/apps/desktop-ui/src/app/app/data-explorer/layout.tsx @@ -0,0 +1,7 @@ +import { generateToolMetadata } from '@/lib/metadata' + +export const metadata = generateToolMetadata('data-explorer') + +export default function DataExplorerLayout({ children }: { children: React.ReactNode }) { + return <>{children} +} diff --git a/apps/desktop-ui/src/app/app/data-explorer/page.tsx b/apps/desktop-ui/src/app/app/data-explorer/page.tsx new file mode 100644 index 00000000..e7702d4b --- /dev/null +++ b/apps/desktop-ui/src/app/app/data-explorer/page.tsx @@ -0,0 +1,5 @@ +"use client"; + +export default function DataExplorerPage() { + return
Data Explorer
; +} diff --git a/apps/desktop-ui/src/components/sidebar/data/sidebar-data.ts b/apps/desktop-ui/src/components/sidebar/data/sidebar-data.ts index 4dc88d37..1c994b8d 100644 --- a/apps/desktop-ui/src/components/sidebar/data/sidebar-data.ts +++ b/apps/desktop-ui/src/components/sidebar/data/sidebar-data.ts @@ -525,6 +525,12 @@ export const sidebarData: SidebarData = { icon: IconBrandRedux, description: 'Browse keys, inspect values, run raw commands, and flush patterns — encrypted credential store.', }, + { + title: 'Data Explorer', + url: '/app/data-explorer', + icon: IconDatabase, + description: 'One workspace for MongoDB and Redis. Browse, query, and edit with an encrypted credential store.', + }, ], }, { diff --git a/apps/desktop-ui/src/lib/metadata.ts b/apps/desktop-ui/src/lib/metadata.ts index 2ae1529b..229520de 100644 --- a/apps/desktop-ui/src/lib/metadata.ts +++ b/apps/desktop-ui/src/lib/metadata.ts @@ -93,6 +93,12 @@ export const toolsMetadata: Record = { keywords: ['redis client', 'redis manager', 'redis browser', 'redis gui', 'redis commander', 'redis keys', 'redis cli'], aiSummary: 'Redis Commander lets you connect to Redis instances, browse and edit all key types (string, list, set, zset, hash), run raw commands via a console, and flush keys by pattern — with credentials encrypted locally.', }, + 'data-explorer': { + title: 'Data Explorer', + description: 'One desktop workspace for MongoDB and Redis. Browse collections, query documents, inspect keys, and run commands with credentials encrypted on your device.', + keywords: ['data explorer', 'mongodb and redis client', 'database gui', 'nosql explorer', 'redis browser', 'multi database client'], + aiSummary: 'Data Explorer is an offline desktop workspace for multiple data sources. Pick MongoDB or Redis per connection and get the full toolset for that source — collection browsing, document queries, aggregation pipelines, index management for MongoDB; key browsing, value editing, pub/sub, and a command console for Redis — with credentials encrypted locally.', + }, 's3-drive': { title: 'S3 Drive', description: 'Manage AWS S3 and DigitalOcean Spaces buckets. Browse, upload, download, and delete files with end-to-end encrypted credentials.', diff --git a/apps/desktop-ui/src/lib/route-config.ts b/apps/desktop-ui/src/lib/route-config.ts index b677a776..ea71efe1 100644 --- a/apps/desktop-ui/src/lib/route-config.ts +++ b/apps/desktop-ui/src/lib/route-config.ts @@ -89,6 +89,7 @@ export const routeConfig: Record = { '/app/api-client': { title: 'API Client', icon: Globe }, '/app/http-status-codes': { title: 'HTTP Status Codes', icon: List, namespace: 'HttpStatusCodes' }, '/app/database-explorer': { title: 'Database Explorer', icon: Database }, + '/app/data-explorer': { title: 'Data Explorer', icon: Database }, '/app/email-validator': { title: 'Email Validator', icon: Globe }, '/app/url-encode': { title: 'URL Encoder', icon: Link2, namespace: 'UrlEncode' }, '/app/uuid-generator': { title: 'UUID Generator', icon: Fingerprint, namespace: 'UuidGenerator' }, diff --git a/apps/desktop-ui/src/lib/tab-registry.tsx b/apps/desktop-ui/src/lib/tab-registry.tsx index 4e4f25b4..86d4c454 100644 --- a/apps/desktop-ui/src/lib/tab-registry.tsx +++ b/apps/desktop-ui/src/lib/tab-registry.tsx @@ -39,6 +39,7 @@ const TAB_REGISTRY: Record = { '/app/api-client': dynamic(() => import('@/app/app/api-client/page'), { ssr: false, loading }), '/app/http-status-codes': dynamic(() => import('@/app/app/http-status-codes/page'), { ssr: false, loading }), '/app/database-explorer': dynamic(() => import('@/app/app/database-explorer/page'), { ssr: false, loading }), + '/app/data-explorer': dynamic(() => import('@/app/app/data-explorer/page'), { ssr: false, loading }), '/app/email-validator': dynamic(() => import('@/app/app/email-validator/page'), { ssr: false, loading }), '/app/url-encode': dynamic(() => import('@/app/app/url-encode/page'), { ssr: false, loading }), '/app/uuid-generator': dynamic(() => import('@/app/app/uuid-generator/page'), { ssr: false, loading }), diff --git a/apps/desktop-ui/src/lib/tool-categories.ts b/apps/desktop-ui/src/lib/tool-categories.ts index 2017b8ed..e2601cb4 100644 --- a/apps/desktop-ui/src/lib/tool-categories.ts +++ b/apps/desktop-ui/src/lib/tool-categories.ts @@ -55,6 +55,7 @@ export const toolCategoryMap: Record = { 'mime-type-lookup': 'Network & API', 'sql-client': 'Database', 'database-explorer': 'Database', + 'data-explorer': 'Database', 's3-drive': 'Database', 'redis-commander': 'Database', 'color-picker': 'Media & Design', diff --git a/apps/desktop-ui/src/lib/tool-i18n.ts b/apps/desktop-ui/src/lib/tool-i18n.ts index 878ff3b0..f398a688 100644 --- a/apps/desktop-ui/src/lib/tool-i18n.ts +++ b/apps/desktop-ui/src/lib/tool-i18n.ts @@ -38,6 +38,7 @@ export const TOOL_PATH_TO_MESSAGE_KEY: Record = { '/app/api-client': 'apiClient', '/app/http-status-codes': 'httpStatusCodes', '/app/database-explorer': 'nosqlExplorer', + '/app/data-explorer': 'dataExplorer', '/app/redis-commander': 'redisCommander', '/app/image-to-base64': 'imageToBase64', '/app/image-compressor': 'imageCompressor', From 78f8b8f72f5c2b3c851a6cf8975063914f712dd2 Mon Sep 17 00:00:00 2001 From: AKHIL Date: Tue, 4 Aug 2026 21:13:10 +0530 Subject: [PATCH 05/26] feat(data-explorer): add shell layout, vault gate, and unified tab bar Co-Authored-By: Claude Opus 5 (1M context) --- .../src/app/app/data-explorer/page.tsx | 215 +++++++++++++++++- .../data-explorer/unified-tab-bar.tsx | 110 +++++++++ 2 files changed, 324 insertions(+), 1 deletion(-) create mode 100644 apps/desktop-ui/src/components/data-explorer/unified-tab-bar.tsx diff --git a/apps/desktop-ui/src/app/app/data-explorer/page.tsx b/apps/desktop-ui/src/app/app/data-explorer/page.tsx index e7702d4b..472015e2 100644 --- a/apps/desktop-ui/src/app/app/data-explorer/page.tsx +++ b/apps/desktop-ui/src/app/app/data-explorer/page.tsx @@ -1,5 +1,218 @@ "use client"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslations } from "next-intl"; +import { toast } from "sonner"; +import { IconAlertTriangle } from "@tabler/icons-react"; +import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"; +import { VisuallyHidden } from "@radix-ui/react-visually-hidden"; +import useAuth from "@/utils/useAuth"; +import { useMasterKeyStore } from "@/store/master-key-store"; +import { useVaultGuard } from "@/hooks/use-vault-guard"; +import { useMediaQuery } from "@/hooks/use-media-query"; +import { VaultLockedPlaceholder } from "@/components/vault-locked-placeholder"; +import { VaultRestoringSkeleton } from "@/components/vault-restoring-skeleton"; +import { UnifiedTabBar } from "@/components/data-explorer/unified-tab-bar"; +import { listConnections, touchConnection } from "@/components/data-explorer/connection-service"; +import { getAdapter } from "@/components/data-explorer/sources"; +import type { + OpenTabRequest, + UnifiedConnection, + UnifiedTab, +} from "@/components/data-explorer/types"; + export default function DataExplorerPage() { - return
Data Explorer
; + const t = useTranslations("DataExplorer"); + const { user } = useAuth(); + const { encryptionKey } = useMasterKeyStore(); + const { isUnlocked, isRestoring } = useVaultGuard(); + const isWide = useMediaQuery("(min-width: 768px)"); + + const [connections, setConnections] = useState([]); + const [tabs, setTabs] = useState([]); + const [activeTabId, setActiveTabId] = useState(null); + const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); + const [isInitialized, setIsInitialized] = useState(false); + + const reloadConnections = useCallback(async () => { + if (!user || !encryptionKey) return; + try { + setConnections(await listConnections(encryptionKey)); + } catch (e) { + toast.error(e instanceof Error ? e.message : "Could not load connections"); + } + }, [user, encryptionKey]); + + useEffect(() => { + void reloadConnections(); + }, [reloadConnections]); + + // Restore tabs. Structural fields only — adapter `state` is rehydrated by + // the pane on activation, so a stale payload can't blow the storage quota. + useEffect(() => { + if (!user) return; + const raw = localStorage.getItem(`data_explorer_tabs_${user.uid}`); + if (raw) { + try { + setTabs(JSON.parse(raw) as UnifiedTab[]); + } catch { + localStorage.removeItem(`data_explorer_tabs_${user.uid}`); + } + } + const savedActive = localStorage.getItem(`data_explorer_active_tab_id_${user.uid}`); + if (savedActive) setActiveTabId(savedActive); + setIsInitialized(true); + }, [user]); + + useEffect(() => { + if (!isInitialized || !user) return; + const key = `data_explorer_tabs_${user.uid}`; + try { + localStorage.setItem(key, JSON.stringify(tabs)); + } catch (e) { + console.warn("data-explorer tabs: localStorage write failed", e); + try { + localStorage.removeItem(key); + } catch { + /* noop */ + } + } + }, [tabs, isInitialized, user]); + + useEffect(() => { + if (!isInitialized || !user) return; + const key = `data_explorer_active_tab_id_${user.uid}`; + try { + if (activeTabId) localStorage.setItem(key, activeTabId); + else localStorage.removeItem(key); + } catch { + /* noop */ + } + }, [activeTabId, isInitialized, user]); + + const openTab = useCallback( + (connection: UnifiedConnection, req: OpenTabRequest) => { + const id = `${connection.id}:${req.key}`; + setMobileSidebarOpen(false); + setTabs((prev) => { + if (prev.some((tab) => tab.id === id)) return prev; + return [ + ...prev, + { + id, + connectionId: connection.id, + sourceId: connection.sourceId, + title: req.title, + subtitle: req.subtitle, + connectionColor: connection.color ?? null, + readOnly: connection.readOnly ?? false, + state: req.state, + }, + ]; + }); + setActiveTabId(id); + void touchConnection(connection.id).catch(() => {}); + }, + [] + ); + + const setTabState = useCallback((tabId: string, updater: (prev: unknown) => unknown) => { + setTabs((prev) => + prev.map((tab) => (tab.id === tabId ? { ...tab, state: updater(tab.state) } : tab)) + ); + }, []); + + const closeTab = useCallback( + (tabId: string) => { + setTabs((prev) => { + const idx = prev.findIndex((tab) => tab.id === tabId); + const next = prev.filter((tab) => tab.id !== tabId); + if (activeTabId === tabId) { + setActiveTabId((prev[idx - 1] ?? prev[idx + 1])?.id ?? null); + } + return next; + }); + }, + [activeTabId] + ); + + const closeAllTabs = useCallback(() => { + setTabs([]); + setActiveTabId(null); + }, []); + + const activeTab = useMemo( + () => tabs.find((tab) => tab.id === activeTabId) ?? null, + [tabs, activeTabId] + ); + const activeConnection = useMemo( + () => (activeTab ? connections.find((c) => c.id === activeTab.connectionId) ?? null : null), + [activeTab, connections] + ); + + if (isRestoring) return ; + if (!isUnlocked) return ; + + // Sidebar arrives in Task 7; connection dialog in Task 6. + const sidebar =
; + + const body = (() => { + if (!activeTab) { + return ( +
+

{t("noTabsTitle")}

+

{t("noTabsBody")}

+
+ ); + } + const adapter = getAdapter(activeTab.sourceId); + if (!adapter || !activeConnection?.config) { + return ( +
+ +

{t("unsupportedSource")}

+

+ {t("unsupportedSourceHint")} +

+
+ ); + } + const Pane = adapter.Pane; + return ( + unknown) => setTabState(activeTab.id, updater)} + /> + ); + })(); + + return ( +
+ {isWide ? ( +
{sidebar}
+ ) : ( + + + + {t("title")} + + {sidebar} + + + )} +
+ +
{body}
+
+
+ ); } diff --git a/apps/desktop-ui/src/components/data-explorer/unified-tab-bar.tsx b/apps/desktop-ui/src/components/data-explorer/unified-tab-bar.tsx new file mode 100644 index 00000000..c8300ed9 --- /dev/null +++ b/apps/desktop-ui/src/components/data-explorer/unified-tab-bar.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { Button } from "@/components/ui/button"; +import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; +import { cn } from "@/lib/utils"; +import { IconX, IconLock, IconAlertTriangle } from "@tabler/icons-react"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { getAdapter } from "./sources"; +import type { UnifiedTab } from "./types"; + +interface UnifiedTabBarProps { + tabs: UnifiedTab[]; + activeTabId: string | null; + onTabChange: (tabId: string) => void; + onTabClose: (tabId: string) => void; + onCloseAll: () => void; +} + +export function UnifiedTabBar({ + tabs, + activeTabId, + onTabChange, + onTabClose, + onCloseAll, +}: UnifiedTabBarProps) { + const t = useTranslations("DataExplorer"); + + // Defensive: a malformed entry would crash the whole strip on `tab.id`. + const safeTabs = tabs.filter((tab): tab is UnifiedTab => !!tab && typeof tab.id === "string"); + + if (safeTabs.length === 0) return
; + + return ( +
+ +
+ {safeTabs.map((tab) => { + const adapter = getAdapter(tab.sourceId); + const Icon = adapter?.icon ?? IconAlertTriangle; + const isActive = activeTabId === tab.id; + return ( + + + +
onTabChange(tab.id)} + style={ + tab.connectionColor + ? { boxShadow: `inset 0 2px 0 0 ${tab.connectionColor}` } + : undefined + } + > + + {tab.readOnly && ( + + )} + + {tab.title} + + +
+
+ +
{tab.title}
+ {tab.subtitle && ( +
{tab.subtitle}
+ )} +
+
+
+ ); + })} +
+ +
+
+ +
+
+ ); +} From a1df403d5d9c7ce225afb4fca5775f940d5c2f93 Mon Sep 17 00:00:00 2001 From: AKHIL Date: Tue, 4 Aug 2026 21:19:48 +0530 Subject: [PATCH 06/26] fix(data-explorer): correct persistence comment, i18n the load-failed toast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings on Task 5: the tab-persistence comment claimed adapter `state` was excluded from localStorage when it never was (state IS persisted on purpose — it restores page/query/sort across sessions, since fetched rows live in the adapter's actions hook, not on the tab). Replaced the comment to describe the actual contract instead of stripping the field. Also routed the "Could not load connections" toast fallback through DataExplorer.toast.loadFailed instead of a hardcoded string. Co-Authored-By: Claude Opus 5 (1M context) --- apps/desktop-ui/messages/en.json | 3 ++- apps/desktop-ui/src/app/app/data-explorer/page.tsx | 9 ++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/desktop-ui/messages/en.json b/apps/desktop-ui/messages/en.json index 5618417a..b20bc923 100644 --- a/apps/desktop-ui/messages/en.json +++ b/apps/desktop-ui/messages/en.json @@ -1911,7 +1911,8 @@ "saved": "Connection saved", "deleted": "Connection deleted", "saveFailed": "Could not save the connection", - "deleteFailed": "Could not delete the connection" + "deleteFailed": "Could not delete the connection", + "loadFailed": "Could not load connections" } }, "ApiClient": { diff --git a/apps/desktop-ui/src/app/app/data-explorer/page.tsx b/apps/desktop-ui/src/app/app/data-explorer/page.tsx index 472015e2..d60d88d1 100644 --- a/apps/desktop-ui/src/app/app/data-explorer/page.tsx +++ b/apps/desktop-ui/src/app/app/data-explorer/page.tsx @@ -39,7 +39,7 @@ export default function DataExplorerPage() { try { setConnections(await listConnections(encryptionKey)); } catch (e) { - toast.error(e instanceof Error ? e.message : "Could not load connections"); + toast.error(e instanceof Error ? e.message : t("toast.loadFailed")); } }, [user, encryptionKey]); @@ -47,8 +47,7 @@ export default function DataExplorerPage() { void reloadConnections(); }, [reloadConnections]); - // Restore tabs. Structural fields only — adapter `state` is rehydrated by - // the pane on activation, so a stale payload can't blow the storage quota. + // Restore tabs, adapter `state` included. useEffect(() => { if (!user) return; const raw = localStorage.getItem(`data_explorer_tabs_${user.uid}`); @@ -64,6 +63,10 @@ export default function DataExplorerPage() { setIsInitialized(true); }, [user]); + // Persists the whole tab array, adapter `state` included — that is what + // restores a Mongo tab's page/query/sort across sessions. Adapter state is + // contractually structural-only: fetched rows live in the adapter's actions + // hook, never on the tab, so this stays far below the localStorage quota. useEffect(() => { if (!isInitialized || !user) return; const key = `data_explorer_tabs_${user.uid}`; From 2e3b24f21c235282e88430257ec2eb5986e13ba2 Mon Sep 17 00:00:00 2001 From: AKHIL Date: Tue, 4 Aug 2026 21:23:21 +0530 Subject: [PATCH 07/26] feat(data-explorer): add connection dialog with source picker Co-Authored-By: Claude Opus 5 (1M context) --- .../src/app/app/data-explorer/page.tsx | 12 ++ .../data-explorer/connection-dialog.tsx | 121 ++++++++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 apps/desktop-ui/src/components/data-explorer/connection-dialog.tsx diff --git a/apps/desktop-ui/src/app/app/data-explorer/page.tsx b/apps/desktop-ui/src/app/app/data-explorer/page.tsx index d60d88d1..f91a75c9 100644 --- a/apps/desktop-ui/src/app/app/data-explorer/page.tsx +++ b/apps/desktop-ui/src/app/app/data-explorer/page.tsx @@ -13,6 +13,7 @@ import { useMediaQuery } from "@/hooks/use-media-query"; import { VaultLockedPlaceholder } from "@/components/vault-locked-placeholder"; import { VaultRestoringSkeleton } from "@/components/vault-restoring-skeleton"; import { UnifiedTabBar } from "@/components/data-explorer/unified-tab-bar"; +import { ConnectionDialog } from "@/components/data-explorer/connection-dialog"; import { listConnections, touchConnection } from "@/components/data-explorer/connection-service"; import { getAdapter } from "@/components/data-explorer/sources"; import type { @@ -33,6 +34,8 @@ export default function DataExplorerPage() { const [activeTabId, setActiveTabId] = useState(null); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [isInitialized, setIsInitialized] = useState(false); + const [connectionDialogOpen, setConnectionDialogOpen] = useState(false); + const [editingConnection, setEditingConnection] = useState(null); const reloadConnections = useCallback(async () => { if (!user || !encryptionKey) return; @@ -216,6 +219,15 @@ export default function DataExplorerPage() { />
{body}
+ { + setConnectionDialogOpen(open); + if (!open) setEditingConnection(null); + }} + editing={editingConnection} + onSaved={() => void reloadConnections()} + />
); } diff --git a/apps/desktop-ui/src/components/data-explorer/connection-dialog.tsx b/apps/desktop-ui/src/components/data-explorer/connection-dialog.tsx new file mode 100644 index 00000000..f8ee3042 --- /dev/null +++ b/apps/desktop-ui/src/components/data-explorer/connection-dialog.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; +import { toast } from "sonner"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { useMasterKeyStore } from "@/store/master-key-store"; +import { SOURCES, SOURCE_ORDER, getAdapter } from "./sources"; +import { saveConnection, updateConnection } from "./connection-service"; +import type { ConnectionFormValues, SourceId, UnifiedConnection } from "./types"; + +interface ConnectionDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** Null when creating. */ + editing: UnifiedConnection | null; + onSaved: () => void; +} + +export function ConnectionDialog({ open, onOpenChange, editing, onSaved }: ConnectionDialogProps) { + const t = useTranslations("DataExplorer"); + const { encryptionKey } = useMasterKeyStore(); + const [sourceId, setSourceId] = useState(null); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!open) return; + setSourceId(editing?.sourceId ?? null); + setError(null); + setSaving(false); + }, [open, editing]); + + const adapter = sourceId ? getAdapter(sourceId) : null; + + async function handleSubmit(values: ConnectionFormValues) { + if (!adapter || !encryptionKey) return; + setError(null); + + const invalid = adapter.validate(values.config); + if (invalid) { + setError(invalid); + return; + } + + setSaving(true); + try { + await adapter.testConnection(values.config); + if (editing) { + await updateConnection(editing.id, values, encryptionKey); + } else { + await saveConnection(adapter.id, values, encryptionKey); + } + toast.success(t("toast.saved")); + onSaved(); + onOpenChange(false); + } catch (e) { + setError(e instanceof Error ? e.message : t("toast.saveFailed")); + } finally { + setSaving(false); + } + } + + return ( + + + + + {editing ? t("connectionDialog.editTitle") : t("connectionDialog.createTitle")} + + + + {!adapter ? ( +
+

+ {t("connectionDialog.pickSource")} +

+
+ {SOURCE_ORDER.map((id) => { + const candidate = SOURCES[id]; + const Icon = candidate.icon; + return ( + + ); + })} +
+
+ ) : ( + onOpenChange(false)} + /> + )} +
+
+ ); +} From a5b6ef089a13c43f10e7a68283367d035d39e708 Mon Sep 17 00:00:00 2001 From: AKHIL Date: Tue, 4 Aug 2026 21:30:35 +0530 Subject: [PATCH 08/26] feat(data-explorer): add unified sidebar with per-source grouping Co-Authored-By: Claude Opus 5 (1M context) --- apps/desktop-ui/messages/en.json | 5 + .../src/app/app/data-explorer/page.tsx | 21 +- .../data-explorer/unified-sidebar.tsx | 392 ++++++++++++++++++ 3 files changed, 416 insertions(+), 2 deletions(-) create mode 100644 apps/desktop-ui/src/components/data-explorer/unified-sidebar.tsx diff --git a/apps/desktop-ui/messages/en.json b/apps/desktop-ui/messages/en.json index b20bc923..6b575b5f 100644 --- a/apps/desktop-ui/messages/en.json +++ b/apps/desktop-ui/messages/en.json @@ -1888,6 +1888,11 @@ "searchConnections": "Search connections", "unsupportedSource": "Unsupported source", "unsupportedSourceHint": "This connection was created by a newer version of the app.", + "noResults": "No connections match your search.", + "menuEdit": "Edit", + "menuDelete": "Delete", + "deleteTitle": "Delete connection?", + "deleteBody": "{name} and its saved credentials will be removed from this device. This cannot be undone.", "closeAll": "Close all", "noTabsTitle": "Nothing open", "noTabsBody": "Pick a connection in the sidebar to open a tab.", diff --git a/apps/desktop-ui/src/app/app/data-explorer/page.tsx b/apps/desktop-ui/src/app/app/data-explorer/page.tsx index f91a75c9..a0e0c6a9 100644 --- a/apps/desktop-ui/src/app/app/data-explorer/page.tsx +++ b/apps/desktop-ui/src/app/app/data-explorer/page.tsx @@ -14,6 +14,7 @@ import { VaultLockedPlaceholder } from "@/components/vault-locked-placeholder"; import { VaultRestoringSkeleton } from "@/components/vault-restoring-skeleton"; import { UnifiedTabBar } from "@/components/data-explorer/unified-tab-bar"; import { ConnectionDialog } from "@/components/data-explorer/connection-dialog"; +import { UnifiedSidebar } from "@/components/data-explorer/unified-sidebar"; import { listConnections, touchConnection } from "@/components/data-explorer/connection-service"; import { getAdapter } from "@/components/data-explorer/sources"; import type { @@ -36,6 +37,8 @@ export default function DataExplorerPage() { const [isInitialized, setIsInitialized] = useState(false); const [connectionDialogOpen, setConnectionDialogOpen] = useState(false); const [editingConnection, setEditingConnection] = useState(null); + // Consumed by the legacy-import dialog in Task 14. + const [importDialogOpen, setImportDialogOpen] = useState(false); const reloadConnections = useCallback(async () => { if (!user || !encryptionKey) return; @@ -159,8 +162,22 @@ export default function DataExplorerPage() { if (isRestoring) return ; if (!isUnlocked) return ; - // Sidebar arrives in Task 7; connection dialog in Task 6. - const sidebar =
; + const sidebar = ( + { + setEditingConnection(null); + setConnectionDialogOpen(true); + }} + onEditConnection={(connection) => { + setEditingConnection(connection); + setConnectionDialogOpen(true); + }} + onConnectionsChanged={() => void reloadConnections()} + onImportLegacy={() => setImportDialogOpen(true)} + /> + ); const body = (() => { if (!activeTab) { diff --git a/apps/desktop-ui/src/components/data-explorer/unified-sidebar.tsx b/apps/desktop-ui/src/components/data-explorer/unified-sidebar.tsx new file mode 100644 index 00000000..b0cde7ec --- /dev/null +++ b/apps/desktop-ui/src/components/data-explorer/unified-sidebar.tsx @@ -0,0 +1,392 @@ +"use client"; + +import React, { useCallback, useMemo, useState } from "react"; +import { useTranslations } from "next-intl"; +import { toast } from "sonner"; +import { motion, AnimatePresence } from "framer-motion"; +import { + IconAlertTriangle, + IconChevronRight, + IconDots, + IconDownload, + IconEdit, + IconLock, + IconPlus, + IconSearch, + IconTrash, +} from "@tabler/icons-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { cn } from "@/lib/utils"; +import { SOURCES, SOURCE_ORDER, getAdapter } from "./sources"; +import { deleteConnection } from "./connection-service"; +import type { OpenTabRequest, SourceId, UnifiedConnection } from "./types"; + +interface UnifiedSidebarProps { + connections: UnifiedConnection[]; + onOpenTab: (connection: UnifiedConnection, req: OpenTabRequest) => void; + onAddConnection: () => void; + onEditConnection: (connection: UnifiedConnection) => void; + onConnectionsChanged: () => void; + onImportLegacy: () => void; +} + +interface ConnectionRowProps { + connection: UnifiedConnection; + expanded: boolean; + onToggle: (connectionId: string) => void; + onOpenTab: (connection: UnifiedConnection, req: OpenTabRequest) => void; + onEdit: (connection: UnifiedConnection) => void; + onDelete: (connection: UnifiedConnection) => void; + onConnectionsChanged: () => void; +} + +const ConnectionRow = React.memo(function ConnectionRow({ + connection, + expanded, + onToggle, + onOpenTab, + onEdit, + onDelete, + onConnectionsChanged, +}: ConnectionRowProps) { + const t = useTranslations("DataExplorer"); + const adapter = getAdapter(connection.sourceId); + const supported = adapter !== null; + + const openTab = useCallback( + (req: OpenTabRequest) => onOpenTab(connection, req), + [onOpenTab, connection] + ); + + const Icon = adapter?.icon ?? IconAlertTriangle; + + return ( +
+
+ + + + + + + {supported && ( + <> + onEdit(connection)}> + + {t("menuEdit")} + + + + )} + onDelete(connection)} + > + + {t("menuDelete")} + + + +
+ + {!supported && ( +

+ {t("unsupportedSource")} +

+ )} + + + {supported && expanded && ( + + + + )} + +
+ ); +}); + +/** Bucket for connections whose sourceId has no registered adapter. */ +const UNSUPPORTED_GROUP = "__unsupported__"; + +export function UnifiedSidebar({ + connections, + onOpenTab, + onAddConnection, + onEditConnection, + onConnectionsChanged, + onImportLegacy, +}: UnifiedSidebarProps) { + const t = useTranslations("DataExplorer"); + const [query, setQuery] = useState(""); + const [expandedIds, setExpandedIds] = useState>(new Set()); + const [collapsedGroups, setCollapsedGroups] = useState>(new Set()); + const [pendingDelete, setPendingDelete] = useState(null); + const [deleting, setDeleting] = useState(false); + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + const safe = connections.filter( + (c): c is UnifiedConnection => !!c && typeof c.id === "string" + ); + return q ? safe.filter((c) => c.name.toLowerCase().includes(q)) : safe; + }, [connections, query]); + + const groups = useMemo(() => { + const byId = new Map(); + for (const connection of filtered) { + const key = getAdapter(connection.sourceId) ? connection.sourceId : UNSUPPORTED_GROUP; + const bucket = byId.get(key); + if (bucket) bucket.push(connection); + else byId.set(key, [connection]); + } + return [...SOURCE_ORDER, UNSUPPORTED_GROUP] + .map((id) => ({ id, items: byId.get(id) ?? [] })) + .filter((group) => group.items.length > 0); + }, [filtered]); + + const toggleConnection = useCallback((connectionId: string) => { + setExpandedIds((prev) => { + const next = new Set(prev); + if (!next.delete(connectionId)) next.add(connectionId); + return next; + }); + }, []); + + const toggleGroup = useCallback((groupId: string) => { + setCollapsedGroups((prev) => { + const next = new Set(prev); + if (!next.delete(groupId)) next.add(groupId); + return next; + }); + }, []); + + const requestDelete = useCallback((connection: UnifiedConnection) => { + setPendingDelete(connection); + }, []); + + async function confirmDelete() { + if (!pendingDelete) return; + setDeleting(true); + try { + await deleteConnection(pendingDelete.id); + toast.success(t("toast.deleted")); + setPendingDelete(null); + onConnectionsChanged(); + } catch (e) { + toast.error(e instanceof Error ? e.message : t("toast.deleteFailed")); + } finally { + setDeleting(false); + } + } + + return ( +
+
+
+ {t("title")} + +
+
+ + setQuery(e.target.value)} + placeholder={t("searchConnections")} + className="h-9 pl-7 text-xs" + /> +
+
+ +
+ {connections.length === 0 ? ( +
+

{t("emptyTitle")}

+

{t("emptyBody")}

+ +
+ ) : groups.length === 0 ? ( +

+ {t("noResults")} +

+ ) : ( + groups.map((group) => { + const source = SOURCES[group.id]; + const GroupIcon = source?.icon ?? IconAlertTriangle; + const open = !collapsedGroups.has(group.id); + return ( +
+ + + {open && ( + + {group.items.map((connection) => ( + + ))} + + )} + +
+ ); + }) + )} +
+ +
+ +
+ + { + if (!open && !deleting) setPendingDelete(null); + }} + > + + + {t("deleteTitle")} + + {t("deleteBody", { name: pendingDelete?.name ?? "" })} + + + + + {t("connectionDialog.cancel")} + + { + e.preventDefault(); + void confirmDelete(); + }} + > + {t("menuDelete")} + + + + +
+ ); +} From 45b438b18190ca4433b680db707fdf7ef485fcee Mon Sep 17 00:00:00 2001 From: AKHIL Date: Tue, 4 Aug 2026 21:37:32 +0530 Subject: [PATCH 09/26] fix(data-explorer): stable sidebar handlers, a11y labels, prune orphaned tabs - useCallback the four sidebar handlers in the shell so React.memo on ConnectionRow survives page-level renders (openTab et al). - aria-label on the row action menu trigger, aria-expanded on the group header and the connection toggle, focus-within reveal for keyboard users. - Drop tabs whose connection was deleted, gated on an explicit connectionsLoaded flag plus isInitialized so a cold start never wipes tabs restored from localStorage and a failed fetch never prunes. Co-Authored-By: Claude Opus 5 (1M context) --- apps/desktop-ui/messages/en.json | 1 + .../src/app/app/data-explorer/page.tsx | 50 +++++++++++++++---- .../data-explorer/unified-sidebar.tsx | 5 +- 3 files changed, 45 insertions(+), 11 deletions(-) diff --git a/apps/desktop-ui/messages/en.json b/apps/desktop-ui/messages/en.json index 6b575b5f..0b0f6641 100644 --- a/apps/desktop-ui/messages/en.json +++ b/apps/desktop-ui/messages/en.json @@ -1889,6 +1889,7 @@ "unsupportedSource": "Unsupported source", "unsupportedSourceHint": "This connection was created by a newer version of the app.", "noResults": "No connections match your search.", + "connectionActions": "Actions for {name}", "menuEdit": "Edit", "menuDelete": "Delete", "deleteTitle": "Delete connection?", diff --git a/apps/desktop-ui/src/app/app/data-explorer/page.tsx b/apps/desktop-ui/src/app/app/data-explorer/page.tsx index a0e0c6a9..77e44917 100644 --- a/apps/desktop-ui/src/app/app/data-explorer/page.tsx +++ b/apps/desktop-ui/src/app/app/data-explorer/page.tsx @@ -39,11 +39,14 @@ export default function DataExplorerPage() { const [editingConnection, setEditingConnection] = useState(null); // Consumed by the legacy-import dialog in Task 14. const [importDialogOpen, setImportDialogOpen] = useState(false); + /** True once a connection fetch has *succeeded*. Gates tab pruning. */ + const [connectionsLoaded, setConnectionsLoaded] = useState(false); const reloadConnections = useCallback(async () => { if (!user || !encryptionKey) return; try { setConnections(await listConnections(encryptionKey)); + setConnectionsLoaded(true); } catch (e) { toast.error(e instanceof Error ? e.message : t("toast.loadFailed")); } @@ -125,6 +128,22 @@ export default function DataExplorerPage() { [] ); + // Drop tabs whose connection was deleted, so they never fall through to the + // "unsupported source" pane. Gated on BOTH flags: `connections` is [] until + // the first fetch resolves and tabs are restored from localStorage on mount, + // so pruning any earlier would wipe every restored tab on a cold start. A + // failed fetch never sets `connectionsLoaded`, so it never prunes either. + // Zero connections is a legitimate loaded state and does prune. + useEffect(() => { + if (!connectionsLoaded || !isInitialized) return; + const live = new Set(connections.map((c) => c.id)); + if (tabs.some((tab) => !live.has(tab.connectionId))) { + setTabs((prev) => prev.filter((tab) => live.has(tab.connectionId))); + } + const active = tabs.find((tab) => tab.id === activeTabId); + if (active && !live.has(active.connectionId)) setActiveTabId(null); + }, [connections, connectionsLoaded, isInitialized, tabs, activeTabId]); + const setTabState = useCallback((tabId: string, updater: (prev: unknown) => unknown) => { setTabs((prev) => prev.map((tab) => (tab.id === tabId ? { ...tab, state: updater(tab.state) } : tab)) @@ -150,6 +169,23 @@ export default function DataExplorerPage() { setActiveTabId(null); }, []); + // Stable identities — the sidebar memoises its connection rows on them. + const handleAddConnection = useCallback(() => { + setEditingConnection(null); + setConnectionDialogOpen(true); + }, []); + + const handleEditConnection = useCallback((connection: UnifiedConnection) => { + setEditingConnection(connection); + setConnectionDialogOpen(true); + }, []); + + const handleConnectionsChanged = useCallback(() => { + void reloadConnections(); + }, [reloadConnections]); + + const handleImportLegacy = useCallback(() => setImportDialogOpen(true), []); + const activeTab = useMemo( () => tabs.find((tab) => tab.id === activeTabId) ?? null, [tabs, activeTabId] @@ -166,16 +202,10 @@ export default function DataExplorerPage() { { - setEditingConnection(null); - setConnectionDialogOpen(true); - }} - onEditConnection={(connection) => { - setEditingConnection(connection); - setConnectionDialogOpen(true); - }} - onConnectionsChanged={() => void reloadConnections()} - onImportLegacy={() => setImportDialogOpen(true)} + onAddConnection={handleAddConnection} + onEditConnection={handleEditConnection} + onConnectionsChanged={handleConnectionsChanged} + onImportLegacy={handleImportLegacy} /> ); diff --git a/apps/desktop-ui/src/components/data-explorer/unified-sidebar.tsx b/apps/desktop-ui/src/components/data-explorer/unified-sidebar.tsx index b0cde7ec..a552f60f 100644 --- a/apps/desktop-ui/src/components/data-explorer/unified-sidebar.tsx +++ b/apps/desktop-ui/src/components/data-explorer/unified-sidebar.tsx @@ -84,6 +84,7 @@ const ConnectionRow = React.memo(function ConnectionRow({ @@ -296,6 +298,7 @@ export function UnifiedSidebar({
+ ))} +
+
+ +
+ + setConnectionString(e.target.value)} + placeholder={DB_DIALECTS[dbType].placeholder} + disabled={saving} + className="font-mono text-sm" + /> +
+ +
+ +
+ + {CONNECTION_COLORS.map((c) => ( +
+
+ +
+
+ +

{t("readOnlyHint")}

+
+ +
+ + {error && ( +
+ {error} +
+ )} + +
+ + +
+ + ); +} + +export const mongodbAdapter: SourceAdapter = { + id: "mongodb", + label: "MongoDB", + icon: IconBrandMongodb, + accent: "text-green-500", + blankConfig, + validate, + testConnection, + ConnectionForm: MongoConnectionForm, + // Replaced in Task 10. + SidebarTree: () => null, + // Replaced in Task 11. + Pane: () => null, +}; diff --git a/apps/desktop-ui/src/components/data-explorer/sources.ts b/apps/desktop-ui/src/components/data-explorer/sources.ts index b77f6bfd..2ec7fff0 100644 --- a/apps/desktop-ui/src/components/data-explorer/sources.ts +++ b/apps/desktop-ui/src/components/data-explorer/sources.ts @@ -1,3 +1,4 @@ +import { mongodbAdapter } from "./adapters/mongodb"; import type { SourceAdapter, SourceId } from "./types"; /** @@ -6,10 +7,12 @@ import type { SourceAdapter, SourceId } from "./types"; * Rust router, or the six global tool registries changes. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -export const SOURCES: Record> = {}; +export const SOURCES: Record> = { + mongodb: mongodbAdapter, +}; /** Display order in the sidebar and the source picker. */ -export const SOURCE_ORDER: SourceId[] = []; +export const SOURCE_ORDER: SourceId[] = ["mongodb"]; /** * Resolve an adapter. Returns null for an unknown id — a connection saved by a From 58b6f40199686411df6018dbd9706d36f90ff52d Mon Sep 17 00:00:00 2001 From: AKHIL Date: Tue, 4 Aug 2026 22:17:54 +0530 Subject: [PATCH 11/26] fix(data-explorer): route MongoDB adapter validation/error copy through i18n MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit validate() now returns DataExplorer-relative i18n keys instead of hardcoded English; the connection dialog resolves them via t(). testConnection's "Connection failed" fallback is gone — it throws empty when there's no sanitised server message, and the dialog supplies connectionDialog.testFailed. Co-Authored-By: Claude Opus 5 (1M context) --- apps/desktop-ui/messages/en.json | 5 ++++ .../__tests__/mongodb-adapter.test.ts | 29 +++++++++++++++---- .../data-explorer/adapters/mongodb.tsx | 8 +++-- .../data-explorer/connection-dialog.tsx | 9 +++--- .../src/components/data-explorer/types.ts | 15 ++++++++-- 5 files changed, 52 insertions(+), 14 deletions(-) diff --git a/apps/desktop-ui/messages/en.json b/apps/desktop-ui/messages/en.json index 71c804f0..afcabc43 100644 --- a/apps/desktop-ui/messages/en.json +++ b/apps/desktop-ui/messages/en.json @@ -1913,6 +1913,7 @@ "readOnlyHint": "Blocks every write operation for this connection.", "test": "Test connection", "testOk": "Connected", + "testFailed": "Could not connect", "save": "Save", "cancel": "Cancel" }, @@ -1922,6 +1923,10 @@ "saveFailed": "Could not save the connection", "deleteFailed": "Could not delete the connection", "loadFailed": "Could not load connections" + }, + "validation": { + "connectionStringRequired": "Enter a connection string.", + "connectionStringScheme": "Connection string must start with mongodb:// or mongodb+srv://." } }, "ApiClient": { diff --git a/apps/desktop-ui/src/components/data-explorer/__tests__/mongodb-adapter.test.ts b/apps/desktop-ui/src/components/data-explorer/__tests__/mongodb-adapter.test.ts index c42b784b..c2e7b783 100644 --- a/apps/desktop-ui/src/components/data-explorer/__tests__/mongodb-adapter.test.ts +++ b/apps/desktop-ui/src/components/data-explorer/__tests__/mongodb-adapter.test.ts @@ -1,17 +1,36 @@ jest.mock("next-intl", () => ({ useTranslations: () => (k: string) => k })) import { mongodbAdapter } from "../adapters/mongodb"; +import messages from "../../../../messages/en.json"; + +/** Resolve a `DataExplorer`-relative key path, e.g. "validation.connectionStringRequired". */ +function resolvesInEnMessages(key: string): boolean { + const parts = key.split("."); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let node: any = (messages as any).DataExplorer; + for (const part of parts) { + if (node == null || typeof node !== "object" || !(part in node)) return false; + node = node[part]; + } + return typeof node === "string" && node.length > 0; +} describe("mongodb adapter", () => { it("starts blank on the generic dialect", () => { expect(mongodbAdapter.blankConfig()).toEqual({ connectionString: "", dbType: "mongodb" }); }); - it("rejects an empty or non-mongo connection string", () => { - expect(mongodbAdapter.validate({ connectionString: "", dbType: "mongodb" })).toBeTruthy(); - expect( - mongodbAdapter.validate({ connectionString: "redis://localhost:6379", dbType: "mongodb" }) - ).toBeTruthy(); + it("rejects an empty or non-mongo connection string with a real i18n key", () => { + const emptyKey = mongodbAdapter.validate({ connectionString: "", dbType: "mongodb" }); + expect(emptyKey).toBe("validation.connectionStringRequired"); + expect(resolvesInEnMessages(emptyKey as string)).toBe(true); + + const schemeKey = mongodbAdapter.validate({ + connectionString: "redis://localhost:6379", + dbType: "mongodb", + }); + expect(schemeKey).toBe("validation.connectionStringScheme"); + expect(resolvesInEnMessages(schemeKey as string)).toBe(true); }); it("accepts mongodb:// and mongodb+srv://", () => { diff --git a/apps/desktop-ui/src/components/data-explorer/adapters/mongodb.tsx b/apps/desktop-ui/src/components/data-explorer/adapters/mongodb.tsx index 1ff1f345..6ca4ce1b 100644 --- a/apps/desktop-ui/src/components/data-explorer/adapters/mongodb.tsx +++ b/apps/desktop-ui/src/components/data-explorer/adapters/mongodb.tsx @@ -39,11 +39,12 @@ function blankConfig(): MongoConfig { return { connectionString: "", dbType: "mongodb" }; } +/** Keys are relative to the `DataExplorer` namespace; the dialog resolves them via t(). */ function validate(config: MongoConfig): string | null { const value = config.connectionString.trim(); - if (!value) return "Enter a connection string."; + if (!value) return "validation.connectionStringRequired"; if (!/^mongodb(\+srv)?:\/\//i.test(value)) { - return "Connection string must start with mongodb:// or mongodb+srv://."; + return "validation.connectionStringScheme"; } return null; } @@ -60,7 +61,8 @@ async function testConnection(config: MongoConfig): Promise { }); const data = await res.json(); // Sanitised so credentials embedded in the connection string never surface. - if (!res.ok || !data.success) throw new Error(sanitizeError(data.error ?? "Connection failed")); + // No server message to report → empty message; the dialog supplies its own copy. + if (!res.ok || !data.success) throw new Error(data.error ? sanitizeError(data.error) : ""); } /** diff --git a/apps/desktop-ui/src/components/data-explorer/connection-dialog.tsx b/apps/desktop-ui/src/components/data-explorer/connection-dialog.tsx index f8ee3042..ad44d32f 100644 --- a/apps/desktop-ui/src/components/data-explorer/connection-dialog.tsx +++ b/apps/desktop-ui/src/components/data-explorer/connection-dialog.tsx @@ -44,9 +44,9 @@ export function ConnectionDialog({ open, onOpenChange, editing, onSaved }: Conne if (!adapter || !encryptionKey) return; setError(null); - const invalid = adapter.validate(values.config); - if (invalid) { - setError(invalid); + const invalidKey = adapter.validate(values.config); + if (invalidKey) { + setError(t(invalidKey)); return; } @@ -62,7 +62,8 @@ export function ConnectionDialog({ open, onOpenChange, editing, onSaved }: Conne onSaved(); onOpenChange(false); } catch (e) { - setError(e instanceof Error ? e.message : t("toast.saveFailed")); + const message = e instanceof Error ? e.message.trim() : ""; + setError(message || t("connectionDialog.testFailed")); } finally { setSaving(false); } diff --git a/apps/desktop-ui/src/components/data-explorer/types.ts b/apps/desktop-ui/src/components/data-explorer/types.ts index aa359341..a718fc02 100644 --- a/apps/desktop-ui/src/components/data-explorer/types.ts +++ b/apps/desktop-ui/src/components/data-explorer/types.ts @@ -99,9 +99,20 @@ export interface SourceAdapter { accent: string; blankConfig: () => Config; - /** Returns a user-facing error message, or null when the config is valid. */ + /** + * Returns an i18n key path relative to the `DataExplorer` namespace (e.g. + * `"validation.connectionStringRequired"`) identifying the problem, or + * `null` when the config is valid. The dialog resolves the key via + * `t(key)` before rendering it — never a raw message. + */ validate: (config: Config) => string | null; - /** Throws with a user-facing, credential-free message on failure. */ + /** + * Throws on failure. When there is a server-derived, already sanitised, + * credential-free message to show, throw with that message and the + * dialog renders it as-is. When there is nothing reportable, throw with + * an empty message and the dialog supplies its own translated fallback + * copy. + */ testConnection: (config: Config) => Promise; ConnectionForm: React.ComponentType>; From ff64a0bd54a15f8b62fe886dc07f508dee39fff7 Mon Sep 17 00:00:00 2001 From: AKHIL Date: Tue, 4 Aug 2026 22:24:35 +0530 Subject: [PATCH 12/26] feat(data-explorer): add Mongo actions hook Co-Authored-By: Claude Opus 5 (1M context) --- apps/desktop-ui/messages/en.json | 4 +- .../src/lib/data-explorer/mongo-actions.ts | 348 ++++++++++++++++++ 2 files changed, 351 insertions(+), 1 deletion(-) create mode 100644 apps/desktop-ui/src/lib/data-explorer/mongo-actions.ts diff --git a/apps/desktop-ui/messages/en.json b/apps/desktop-ui/messages/en.json index afcabc43..20321667 100644 --- a/apps/desktop-ui/messages/en.json +++ b/apps/desktop-ui/messages/en.json @@ -1922,7 +1922,9 @@ "deleted": "Connection deleted", "saveFailed": "Could not save the connection", "deleteFailed": "Could not delete the connection", - "loadFailed": "Could not load connections" + "loadFailed": "Could not load connections", + "fetchFailed": "Failed to fetch documents for {collection}", + "docDeleted": "Document deleted" }, "validation": { "connectionStringRequired": "Enter a connection string.", diff --git a/apps/desktop-ui/src/lib/data-explorer/mongo-actions.ts b/apps/desktop-ui/src/lib/data-explorer/mongo-actions.ts new file mode 100644 index 00000000..749fd4e3 --- /dev/null +++ b/apps/desktop-ui/src/lib/data-explorer/mongo-actions.ts @@ -0,0 +1,348 @@ +// ponytail: handler bodies copied from src/app/app/database-explorer/page.tsx +// (lines 215-583) rather than extracted, because the existing tool must not be +// modified. If these two copies start drifting, extract a shared hook and point +// both pages at it. + +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslations } from "next-intl"; +import { toast } from "sonner"; +import { apiFetch } from "@/lib/desktop/api-fetch"; +import { normalizeConnectionString } from "@/lib/nosql-dialects"; +import { sanitizeError } from "@/lib/nosql-error-sanitizer"; +import type { MongoConfig, MongoTabState } from "@/components/data-explorer/adapters/mongodb"; +import type { Document } from "@/components/nosql-explorer/types"; + +export interface MongoActions { + documents: Document[]; + total: number; + loading: boolean; + error: string | null; + refresh: () => Promise; + insert: (doc: unknown) => Promise; + update: (id: string, update: unknown, mode?: "merge" | "replace") => Promise; + remove: (id: string) => Promise; + bulkDelete: (ids: string[]) => Promise; + importDocuments: (documents: unknown[]) => Promise; + loadSchema: (opts: { sampleMode: string; sampleSize: number }) => Promise<{ + fields: unknown[]; + sampleSize: number; + validator?: unknown; + }>; + loadIndexes: () => Promise<{ indexes: unknown[]; totalIndexSize?: number; stats?: unknown }>; + dropIndex: (indexName: string) => Promise; + createIndex: (keys: Record, options: Record) => Promise; + explain: (query: string) => Promise; + previewPipeline: (pipelineJson: string) => Promise<{ documents: unknown[] }>; +} + +/** + * Every MongoDB API call the Data Explorer document pane makes. + * + * The shell resolved the connection already, so this hook takes the raw config + * and the read-only flag instead of looking a connection up. Server errors are + * always sanitised before they reach a toast or a thrown message — connection + * strings carry passwords. + */ +export function useMongoActions( + config: MongoConfig, + state: MongoTabState, + readOnly: boolean +): MongoActions { + const t = useTranslations("DataExplorer.toast"); + const [documents, setDocuments] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // DocumentDB / Cosmos DB reject the driver's default retryWrites=true. + const connectionString = useMemo( + () => normalizeConnectionString(config.dbType, config.connectionString), + [config.dbType, config.connectionString] + ); + + const { dbName, collectionName, page, limit, query, sortField, sortDirection } = state; + + const refresh = useCallback(async () => { + setLoading(true); + setError(null); + try { + const skip = (page - 1) * limit; + const res = await apiFetch("/api/nosql/documents/query", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + connectionString, + dbName, + collectionName, + query, + limit, + skip, + sortField, + sortDirection: sortDirection || "asc", + }), + }); + const data = await res.json(); + + if (!res.ok) throw new Error(sanitizeError(data.error)); + + setDocuments(data.documents); + setTotal(data.total); + setLoading(false); + } catch (e: unknown) { + setLoading(false); + setError(sanitizeError(e)); + toast.error(t("fetchFailed", { collection: collectionName })); + } + }, [connectionString, dbName, collectionName, page, limit, query, sortField, sortDirection, t]); + + // A setState on the tab is what triggers a refetch now — the legacy page + // called performFetch imperatively from every handler instead. + useEffect(() => { + void refresh(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dbName, collectionName, page, limit, query, sortField, sortDirection]); + + const insert = async (doc: unknown) => { + try { + const res = await apiFetch("/api/nosql/documents", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + connectionString, + readOnly, + dbName, + collectionName, + document: doc, + }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(sanitizeError(data.error)); + await refresh(); + } catch (e: unknown) { + toast.error(sanitizeError(e)); + throw e; + } + }; + + const update = async (id: string, updateDoc: unknown, mode: "merge" | "replace" = "merge") => { + try { + const res = await apiFetch("/api/nosql/documents", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + connectionString, + readOnly, + dbName, + collectionName, + documentId: id, + update: updateDoc, + mode, + }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(sanitizeError(data.error)); + await refresh(); + } catch (e: unknown) { + toast.error(sanitizeError(e)); + throw e; + } + }; + + const remove = async (id: string) => { + try { + const res = await apiFetch("/api/nosql/documents", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + connectionString, + readOnly, + dbName, + collectionName, + documentId: id, + }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(sanitizeError(data.error)); + toast.success(t("docDeleted")); + await refresh(); + } catch (e: unknown) { + toast.error(sanitizeError(e)); + throw e; + } + }; + + const bulkDelete = async (ids: string[]) => { + try { + const res = await apiFetch("/api/nosql/bulk-delete", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + connectionString, + readOnly, + dbName, + collectionName, + documentIds: ids, + }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(sanitizeError(data.error)); + await refresh(); + } catch (e: unknown) { + toast.error(sanitizeError(e)); + throw e; + } + }; + + const importDocuments = async (docs: unknown[]) => { + try { + const res = await apiFetch("/api/nosql/import", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + connectionString, + readOnly, + dbName, + collectionName, + documents: docs, + }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(sanitizeError(data.error)); + await refresh(); + } catch (e: unknown) { + toast.error(sanitizeError(e)); + throw e; + } + }; + + const loadSchema = useCallback( + async (opts?: { sampleMode: string; sampleSize: number }) => { + const res = await apiFetch("/api/nosql/schema", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + connectionString, + dbName, + collectionName, + sampleMode: opts?.sampleMode ?? "random", + sampleSize: opts?.sampleSize ?? 200, + }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(sanitizeError(data.error)); + return data; + }, + [connectionString, dbName, collectionName] + ); + + const loadIndexes = useCallback(async () => { + const res = await apiFetch("/api/nosql/indexes/list", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + connectionString, + dbName, + collectionName, + }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(sanitizeError(data.error)); + return data; + }, [connectionString, dbName, collectionName]); + + const dropIndex = async (indexName: string) => { + const res = await apiFetch("/api/nosql/indexes", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + connectionString, + readOnly, + dbName, + collectionName, + indexName, + }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(sanitizeError(data.error)); + }; + + const createIndex = async (keys: Record, options: Record) => { + const res = await apiFetch("/api/nosql/indexes", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + connectionString, + readOnly, + dbName, + collectionName, + keys, + options, + }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(sanitizeError(data.error)); + }; + + const explain = useCallback( + async (explainQuery: string) => { + const res = await apiFetch("/api/nosql/explain", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + connectionString, + dbName, + collectionName, + query: explainQuery, + sortField, + sortDirection: sortDirection || "asc", + }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(sanitizeError(data.error)); + return data.explain; + }, + [connectionString, dbName, collectionName, sortField, sortDirection] + ); + + const previewPipeline = useCallback( + async (pipelineJson: string) => { + const res = await apiFetch("/api/nosql/documents/query", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + connectionString, + dbName, + collectionName, + query: pipelineJson, + limit: 10, + skip: 0, + }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(sanitizeError(data.error)); + return { documents: data.documents as unknown[] }; + }, + [connectionString, dbName, collectionName] + ); + + return { + documents, + total, + loading, + error, + refresh, + insert, + update, + remove, + bulkDelete, + importDocuments, + loadSchema, + loadIndexes, + dropIndex, + createIndex, + explain, + previewPipeline, + }; +} From 1393aa423a9427526fc0a7e63fec3ff96f103d23 Mon Sep 17 00:00:00 2001 From: AKHIL Date: Tue, 4 Aug 2026 22:35:42 +0530 Subject: [PATCH 13/26] feat(data-explorer): add MongoDB sidebar tree Co-Authored-By: Claude Opus 5 (1M context) --- apps/desktop-ui/messages/en.json | 28 + .../data-explorer/adapters/mongodb.tsx | 609 +++++++++++++++++- 2 files changed, 630 insertions(+), 7 deletions(-) diff --git a/apps/desktop-ui/messages/en.json b/apps/desktop-ui/messages/en.json index 20321667..ff8b9bbb 100644 --- a/apps/desktop-ui/messages/en.json +++ b/apps/desktop-ui/messages/en.json @@ -1929,6 +1929,34 @@ "validation": { "connectionStringRequired": "Enter a connection string.", "connectionStringScheme": "Connection string must start with mongodb:// or mongodb+srv://." + }, + "mongo": { + "refreshDatabases": "Refresh databases", + "serverMonitor": "Server monitor", + "connecting": "Connecting…", + "connectionFailed": "Could not connect", + "noDatabases": "No databases", + "loadingCollections": "Loading collections…", + "collectionsFailed": "Could not load collections", + "noCollections": "No collections", + "databaseActions": "Actions for database {name}", + "collectionActions": "Actions for collection {name}", + "refresh": "Refresh", + "rename": "Rename", + "browseGridfs": "Browse GridFS", + "sync": "Sync collection", + "dropDatabase": "Drop database", + "dropCollection": "Drop collection", + "toast": { + "connectFailed": "Could not connect to {name}", + "collectionsFailed": "Could not load collections for {name}", + "invalidDbName": "That database name is not valid.", + "invalidCollectionName": "That collection name is not valid.", + "renamed": "Renamed to {name}", + "renameFailed": "Could not rename", + "dropped": "Dropped {name}", + "dropFailed": "Could not drop {name}" + } } }, "ApiClient": { diff --git a/apps/desktop-ui/src/components/data-explorer/adapters/mongodb.tsx b/apps/desktop-ui/src/components/data-explorer/adapters/mongodb.tsx index 6ca4ce1b..34ac0f35 100644 --- a/apps/desktop-ui/src/components/data-explorer/adapters/mongodb.tsx +++ b/apps/desktop-ui/src/components/data-explorer/adapters/mongodb.tsx @@ -1,16 +1,41 @@ "use client"; -import type React from "react"; -import { useState } from "react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslations } from "next-intl"; -import { IconBrandMongodb, IconX } from "@tabler/icons-react"; +import { toast } from "sonner"; +import { + IconActivity, + IconAlertCircle, + IconArrowsExchange, + IconBrandMongodb, + IconChevronRight, + IconDatabase, + IconDots, + IconEdit, + IconFiles, + IconFolder, + IconRefresh, + IconTrash, + IconX, +} from "@tabler/icons-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { cn } from "@/lib/utils"; import { apiFetch } from "@/lib/desktop/api-fetch"; -import { sanitizeError } from "@/lib/nosql-error-sanitizer"; +import { + sanitizeError, + validateCollectionName, + validateDbName, +} from "@/lib/nosql-error-sanitizer"; import { DB_DIALECTS, DB_TYPE_ORDER, @@ -18,7 +43,20 @@ import { type DbType, } from "@/lib/nosql-dialects"; import { CONNECTION_COLORS } from "@/components/nosql-explorer/connection-form"; -import type { ConnectionFormProps, SourceAdapter } from "../types"; +import { + SidebarDialogs, + type BulkDeleteState, + type DeleteConnState, + type DropCollState, + type DropDbState, + type RenameCollectionState, + type RenameDatabaseState, +} from "@/components/nosql-explorer/sidebar-dialogs"; +import { GridFsBrowser } from "@/components/nosql-explorer/gridfs-browser"; +import { SyncDialog } from "@/components/nosql-explorer/sync-dialog"; +import { ServerMonitor } from "@/components/nosql-explorer/server-monitor"; +import type { Collection, Database, SavedConnection } from "@/components/nosql-explorer/types"; +import type { ConnectionFormProps, SidebarTreeProps, SourceAdapter } from "../types"; export interface MongoConfig { connectionString: string; @@ -200,6 +238,564 @@ function MongoConnectionForm({ initial, saving, error, onSubmit, onCancel }: Con ); } +/* ------------------------------------------------------------------ tree */ + +/** + * POST helper for every tree endpoint. Errors are sanitised here so a + * connection string (which carries the password) can never reach a toast. + */ +async function post(path: string, body: Record): Promise { + const res = await apiFetch(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok || data?.success === false) throw new Error(sanitizeError(data?.error ?? "")); + return data as T; +} + +/** Sanitised server message when there is one, otherwise translated fallback. */ +function errorText(e: unknown, fallback: string): string { + const message = e instanceof Error ? e.message : ""; + return message || fallback; +} + +function formatCount(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; + return String(n); +} + +interface CollectionsState { + loading: boolean; + items: Collection[] | null; + error: string | null; +} + +interface CollectionRowProps { + dbName: string; + collection: Collection; + readOnly: boolean; + /** All callbacks take the entity as arguments so the parent can keep them + * `useCallback`-stable — an inline arrow per row would defeat React.memo. */ + onOpen: (dbName: string, collectionName: string) => void; + onRename: (dbName: string, collectionName: string) => void; + onSync: (dbName: string, collectionName: string) => void; + onDrop: (dbName: string, collectionName: string) => void; +} + +const CollectionRow = React.memo(function CollectionRow({ + dbName, + collection, + readOnly, + onOpen, + onRename, + onSync, + onDrop, +}: CollectionRowProps) { + const t = useTranslations("DataExplorer"); + const count = collection.documentCount; + + return ( +
+ + {!readOnly && ( + + + + + + onRename(dbName, collection.name)}> + + {t("mongo.rename")} + + onSync(dbName, collection.name)}> + + {t("mongo.sync")} + + + onDrop(dbName, collection.name)} + > + + {t("mongo.dropCollection")} + + + + )} +
+ ); +}); + +// Closed-dialog constants: stable identities, and the shared `SidebarDialogs` +// only reads `open` / names / `newName` from these — `connection` stays null +// because this tree already owns the target connection. +const CLOSED_RENAME_COLL: RenameCollectionState = { + open: false, connection: null, dbName: "", collectionName: "", newName: "", +}; +const CLOSED_RENAME_DB: RenameDatabaseState = { + open: false, connection: null, dbName: "", newName: "", +}; +const CLOSED_DROP_DB: DropDbState = { open: false, connIndex: null, dbName: "" }; +const CLOSED_DROP_COLL: DropCollState = { + open: false, connIndex: null, dbName: "", collectionName: "", +}; +// Connection delete and bulk delete live in the unified shell, not in this +// tree — those branches of `SidebarDialogs` stay permanently closed. +const NO_DELETE_CONN: DeleteConnState = { open: false, index: null }; +const NO_BULK: BulkDeleteState = { open: false }; +const NO_SELECTION = new Set(); +const NO_ROWS: never[] = []; +const noop = () => { }; + +function MongoSidebarTree({ + connection, + config, + openTab, + onConnectionsChanged, +}: SidebarTreeProps) { + const t = useTranslations("DataExplorer"); + const readOnly = !!connection.readOnly; + // DocumentDB/Cosmos reject the driver default retryWrites=true. + const connectionString = useMemo( + () => normalizeConnectionString(config?.dbType ?? "mongodb", config?.connectionString ?? ""), + [config?.dbType, config?.connectionString] + ); + + const [databases, setDatabases] = useState(null); + const [dbLoading, setDbLoading] = useState(false); + const [dbError, setDbError] = useState(null); + const [expanded, setExpanded] = useState>(() => new Set()); + const [collections, setCollections] = useState>({}); + + const [monitorOpen, setMonitorOpen] = useState(false); + const [gridfsDb, setGridfsDb] = useState(null); + const [syncSource, setSyncSource] = useState<{ dbName: string; collectionName: string } | null>(null); + const [renameColl, setRenameColl] = useState(CLOSED_RENAME_COLL); + const [renameDb, setRenameDb] = useState(CLOSED_RENAME_DB); + const [dropDb, setDropDb] = useState(CLOSED_DROP_DB); + const [dropColl, setDropColl] = useState(CLOSED_DROP_COLL); + + /** There is no databases endpoint — the list comes back from connect. */ + const loadDatabases = useCallback(async () => { + setDbLoading(true); + setDbError(null); + try { + const data = await post<{ databases?: Database[] }>("/api/nosql/connect", { + connectionString, + }); + setDatabases(data.databases ?? []); + } catch (e) { + setDatabases(null); + setDbError(errorText(e, t("mongo.connectionFailed"))); + toast.error(t("mongo.toast.connectFailed", { name: connection.name })); + } finally { + setDbLoading(false); + } + }, [connectionString, connection.name, t]); + + const loadCollections = useCallback( + async (dbName: string) => { + setCollections((prev) => ({ + ...prev, + [dbName]: { loading: true, items: prev[dbName]?.items ?? null, error: null }, + })); + try { + const data = await post<{ collections?: Collection[] }>("/api/nosql/collections", { + connectionString, + dbName, + }); + setCollections((prev) => ({ + ...prev, + [dbName]: { loading: false, items: data.collections ?? [], error: null }, + })); + } catch (e) { + setCollections((prev) => ({ + ...prev, + [dbName]: { + loading: false, + items: null, + error: errorText(e, t("mongo.collectionsFailed")), + }, + })); + toast.error(t("mongo.toast.collectionsFailed", { name: dbName })); + } + }, + [connectionString, t] + ); + + // The tree only mounts once its connection row is expanded, so mounting is + // the expand. Keyed on the server alone: `loadDatabases` also closes over + // `t`, whose identity next-intl does not guarantee across renders. + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => { void loadDatabases(); }, [connectionString]); + + const toggleDatabase = useCallback( + (dbName: string) => { + const isOpen = expanded.has(dbName); + setExpanded((prev) => { + const next = new Set(prev); + if (isOpen) next.delete(dbName); + else next.add(dbName); + return next; + }); + if (!isOpen && !collections[dbName]) void loadCollections(dbName); + }, + [expanded, collections, loadCollections] + ); + + const openCollection = useCallback( + (dbName: string, collectionName: string) => { + openTab({ + key: `${dbName}/${collectionName}`, + title: collectionName, + subtitle: `${connection.name} › ${dbName}`, + state: { + dbName, + collectionName, + page: 1, + limit: 50, + query: "{}", + sortField: null, + sortDirection: "asc", + }, + }); + }, + [openTab, connection.name] + ); + + const requestRenameCollection = useCallback((dbName: string, collectionName: string) => { + setRenameColl({ open: true, connection: null, dbName, collectionName, newName: collectionName }); + }, []); + + const requestSync = useCallback((dbName: string, collectionName: string) => { + setSyncSource({ dbName, collectionName }); + }, []); + + const requestDropCollection = useCallback((dbName: string, collectionName: string) => { + setDropColl({ open: true, connIndex: 0, dbName, collectionName }); + }, []); + + async function handleRenameCollection() { + const { dbName, collectionName, newName } = renameColl; + const name = newName.trim(); + if (!validateCollectionName(name).valid) { + toast.error(t("mongo.toast.invalidCollectionName")); + return; + } + try { + await post("/api/nosql/collection/rename", { + connectionString, + dbName, + collectionName, + newCollectionName: name, + }); + toast.success(t("mongo.toast.renamed", { name })); + setRenameColl(CLOSED_RENAME_COLL); + void loadCollections(dbName); + } catch (e) { + toast.error(errorText(e, t("mongo.toast.renameFailed"))); + } + } + + async function handleRenameDatabase() { + const { dbName, newName } = renameDb; + const name = newName.trim(); + if (!validateDbName(name).valid) { + toast.error(t("mongo.toast.invalidDbName")); + return; + } + try { + await post("/api/nosql/database/rename", { + connectionString, + oldDbName: dbName, + newDbName: name, + }); + toast.success(t("mongo.toast.renamed", { name })); + setRenameDb(CLOSED_RENAME_DB); + void loadDatabases(); + } catch (e) { + toast.error(errorText(e, t("mongo.toast.renameFailed"))); + } + } + + async function handleDropDatabase() { + const { dbName } = dropDb; + try { + await post("/api/nosql/database/drop", { connectionString, dbName }); + toast.success(t("mongo.toast.dropped", { name: dbName })); + void loadDatabases(); + onConnectionsChanged(); + } catch (e) { + toast.error(errorText(e, t("mongo.toast.dropFailed", { name: dbName }))); + } finally { + setDropDb(CLOSED_DROP_DB); + } + } + + async function handleDropCollection() { + const { dbName, collectionName } = dropColl; + try { + await post("/api/nosql/collection/drop", { connectionString, dbName, collectionName }); + toast.success(t("mongo.toast.dropped", { name: collectionName })); + void loadCollections(dbName); + onConnectionsChanged(); + } catch (e) { + toast.error(errorText(e, t("mongo.toast.dropFailed", { name: collectionName }))); + } finally { + setDropColl(CLOSED_DROP_COLL); + } + } + + // SyncDialog picks its target from saved connections; this tree only knows + // its own, so it is the only offered target (same server, other db/collection). + const syncTargets = useMemo( + () => [ + { + id: connection.id, + userId: connection.userId, + encryptedData: connection.encryptedData, + iv: connection.iv, + connectionString, + name: connection.name, + color: connection.color, + readOnly: connection.readOnly, + dbType: config?.dbType, + createdAt: connection.createdAt, + lastUsedAt: connection.lastUsedAt, + }, + ], + [connection, connectionString, config?.dbType] + ); + + return ( +
+
+ + +
+ + {dbLoading && databases === null ? ( +

{t("mongo.connecting")}

+ ) : dbError ? ( +

+ + {t("mongo.connectionFailed")} +

+ ) : !databases || databases.length === 0 ? ( +

{t("mongo.noDatabases")}

+ ) : ( + databases.map((db) => { + const isOpen = expanded.has(db.name); + const state = collections[db.name]; + return ( +
+
+ + + + + + + void loadCollections(db.name)}> + + {t("mongo.refresh")} + + setGridfsDb(db.name)}> + + {t("mongo.browseGridfs")} + + {!readOnly && ( + <> + + setRenameDb({ + open: true, + connection: null, + dbName: db.name, + newName: db.name, + }) + } + > + + {t("mongo.rename")} + + + + setDropDb({ open: true, connIndex: 0, dbName: db.name }) + } + > + + {t("mongo.dropDatabase")} + + + )} + + +
+ + {isOpen && ( +
+ {state?.loading || !state ? ( +

+ {t("mongo.loadingCollections")} +

+ ) : state.error ? ( +

+ + {t("mongo.collectionsFailed")} +

+ ) : !state.items || state.items.length === 0 ? ( +

+ {t("mongo.noCollections")} +

+ ) : ( + state.items.map((col) => ( + + )) + )} +
+ )} +
+ ); + }) + )} + + void handleRenameCollection()} + renameDatabaseDialog={renameDb} + setRenameDatabaseDialog={setRenameDb} + onRenameDatabase={() => void handleRenameDatabase()} + deleteConnDialog={NO_DELETE_CONN} + setDeleteConnDialog={noop} + onConfirmDeleteConnection={noop} + dropDbDialog={dropDb} + setDropDbDialog={setDropDb} + onConfirmDropDatabase={() => void handleDropDatabase()} + dropCollDialog={dropColl} + setDropCollDialog={setDropColl} + onConfirmDropCollection={() => void handleDropCollection()} + bulkDeleteDialog={NO_BULK} + setBulkDeleteDialog={noop} + selectedCollections={NO_SELECTION} + isBulkDeleting={false} + onConfirmBulkDelete={noop} + /> + + {monitorOpen && ( + setMonitorOpen(false)} + /> + )} + {gridfsDb && ( + setGridfsDb(null)} + /> + )} + {syncSource && ( + setSyncSource(null)} + /> + )} +
+ ); +} + export const mongodbAdapter: SourceAdapter = { id: "mongodb", label: "MongoDB", @@ -209,8 +805,7 @@ export const mongodbAdapter: SourceAdapter = { validate, testConnection, ConnectionForm: MongoConnectionForm, - // Replaced in Task 10. - SidebarTree: () => null, + SidebarTree: MongoSidebarTree, // Replaced in Task 11. Pane: () => null, }; From 21060e3ea6470ffded813a965f69328b01617015 Mon Sep 17 00:00:00 2001 From: AKHIL Date: Tue, 4 Aug 2026 22:50:30 +0530 Subject: [PATCH 14/26] =?UTF-8?q?feat(data-explorer):=20restore=20Mongo=20?= =?UTF-8?q?tree=20parity=20=E2=80=94=20bulk=20delete,=20search,=20cross-se?= =?UTF-8?q?rver=20sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds copy-db-name, collection multi-select + bulk delete, in-tree search filtering, and cross-server sync targets. SidebarTreeProps gains searchQuery and siblings (source-agnostic: the shell buckets by sourceId equality). Also: per-mode name-validation messages, sorted collections, no No-databases flash, sanitizeError on every error path. Co-Authored-By: Claude Opus 5 (1M context) --- apps/desktop-ui/messages/en.json | 19 +- .../__tests__/mongodb-adapter.test.ts | 22 +- .../data-explorer/adapters/mongodb.tsx | 254 +++++++++++++++--- .../src/components/data-explorer/types.ts | 14 + .../data-explorer/unified-sidebar.tsx | 40 ++- 5 files changed, 303 insertions(+), 46 deletions(-) diff --git a/apps/desktop-ui/messages/en.json b/apps/desktop-ui/messages/en.json index ff8b9bbb..bc7ab1b8 100644 --- a/apps/desktop-ui/messages/en.json +++ b/apps/desktop-ui/messages/en.json @@ -1939,10 +1939,15 @@ "loadingCollections": "Loading collections…", "collectionsFailed": "Could not load collections", "noCollections": "No collections", + "noMatches": "No databases match your search", "databaseActions": "Actions for database {name}", "collectionActions": "Actions for collection {name}", + "selectCollection": "Select collection {name}", + "deleteSelected": "Delete {count} selected", + "clearSelection": "Clear", "refresh": "Refresh", "rename": "Rename", + "copyDbName": "Copy database name", "browseGridfs": "Browse GridFS", "sync": "Sync collection", "dropDatabase": "Drop database", @@ -1950,12 +1955,20 @@ "toast": { "connectFailed": "Could not connect to {name}", "collectionsFailed": "Could not load collections for {name}", - "invalidDbName": "That database name is not valid.", - "invalidCollectionName": "That collection name is not valid.", + "dbNameCopied": "Database name copied", + "nameEmpty": "Enter a name.", + "nameTooLong": "That name is too long.", + "nameForbiddenChars": "A database name cannot contain / \\ . or $", + "nameConsecutiveDots": "A database name cannot contain consecutive dots.", + "nameNullChar": "A collection name cannot contain a null character.", + "nameReservedPrefix": "A collection name cannot start with $ — that prefix is reserved.", + "nameInvalid": "That name is not valid.", "renamed": "Renamed to {name}", "renameFailed": "Could not rename", "dropped": "Dropped {name}", - "dropFailed": "Could not drop {name}" + "dropFailed": "Could not drop {name}", + "bulkDropped": "Dropped {count} collections", + "bulkDropFailed": "Could not drop {count} collections" } } }, diff --git a/apps/desktop-ui/src/components/data-explorer/__tests__/mongodb-adapter.test.ts b/apps/desktop-ui/src/components/data-explorer/__tests__/mongodb-adapter.test.ts index c2e7b783..b4dacfa8 100644 --- a/apps/desktop-ui/src/components/data-explorer/__tests__/mongodb-adapter.test.ts +++ b/apps/desktop-ui/src/components/data-explorer/__tests__/mongodb-adapter.test.ts @@ -1,6 +1,7 @@ jest.mock("next-intl", () => ({ useTranslations: () => (k: string) => k })) -import { mongodbAdapter } from "../adapters/mongodb"; +import { mongodbAdapter, mongoNameErrorKey } from "../adapters/mongodb"; +import { validateCollectionName, validateDbName } from "@/lib/nosql-error-sanitizer"; import messages from "../../../../messages/en.json"; /** Resolve a `DataExplorer`-relative key path, e.g. "validation.connectionStringRequired". */ @@ -45,6 +46,25 @@ describe("mongodb adapter", () => { ).toBeNull(); }); + // The mapper matches on the validator's English prose, so it breaks silently + // if that prose changes. This locks each failure mode to a distinct, real key. + it("maps every name-validator failure mode to its own existing key", () => { + const cases: Array<[string, string]> = [ + [mongoNameErrorKey(validateDbName("")), "mongo.toast.nameEmpty"], + [mongoNameErrorKey(validateDbName("d".repeat(65))), "mongo.toast.nameTooLong"], + [mongoNameErrorKey(validateDbName("bad/name")), "mongo.toast.nameForbiddenChars"], + [mongoNameErrorKey(validateCollectionName("a\0b")), "mongo.toast.nameNullChar"], + [mongoNameErrorKey(validateCollectionName("$reserved")), "mongo.toast.nameReservedPrefix"], + [mongoNameErrorKey(validateCollectionName("c".repeat(121))), "mongo.toast.nameTooLong"], + ]; + for (const [actual, expected] of cases) { + expect(actual).toBe(expected); + expect(resolvesInEnMessages(actual)).toBe(true); + } + expect(resolvesInEnMessages("mongo.toast.nameInvalid")).toBe(true); + expect(resolvesInEnMessages("mongo.toast.nameConsecutiveDots")).toBe(true); + }); + it("identifies itself consistently", () => { expect(mongodbAdapter.id).toBe("mongodb"); expect(mongodbAdapter.label).toBe("MongoDB"); diff --git a/apps/desktop-ui/src/components/data-explorer/adapters/mongodb.tsx b/apps/desktop-ui/src/components/data-explorer/adapters/mongodb.tsx index 34ac0f35..514000b8 100644 --- a/apps/desktop-ui/src/components/data-explorer/adapters/mongodb.tsx +++ b/apps/desktop-ui/src/components/data-explorer/adapters/mongodb.tsx @@ -9,6 +9,7 @@ import { IconArrowsExchange, IconBrandMongodb, IconChevronRight, + IconCopy, IconDatabase, IconDots, IconEdit, @@ -30,6 +31,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { cn } from "@/lib/utils"; +import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"; import { apiFetch } from "@/lib/desktop/api-fetch"; import { sanitizeError, @@ -255,12 +257,34 @@ async function post(path: string, body: Record): Promise return data as T; } -/** Sanitised server message when there is one, otherwise translated fallback. */ +/** + * Sanitised server message when there is one, otherwise translated fallback. + * Sanitised again here: `post` already sanitises what it throws, but this also + * runs on errors from anywhere else, and connection strings carry passwords. + */ function errorText(e: unknown, fallback: string): string { - const message = e instanceof Error ? e.message : ""; + const message = e instanceof Error ? sanitizeError(e.message) : ""; return message || fallback; } +/** + * Maps a `validateDbName` / `validateCollectionName` failure onto its own + * translated key, so the user learns *why* the name was rejected. The library + * returns English prose; matching its distinct phrases keeps the reason precise + * without duplicating the rules. Exported so a unit test locks the mapping to + * the library's real output. + */ +export function mongoNameErrorKey(result: { valid: boolean; error?: string }): string { + const e = result.error ?? ""; + if (/non-empty/i.test(e)) return "mongo.toast.nameEmpty"; + if (/exceed/i.test(e)) return "mongo.toast.nameTooLong"; + if (/forbidden characters/i.test(e)) return "mongo.toast.nameForbiddenChars"; + if (/consecutive dots/i.test(e)) return "mongo.toast.nameConsecutiveDots"; + if (/null character/i.test(e)) return "mongo.toast.nameNullChar"; + if (/start with/i.test(e)) return "mongo.toast.nameReservedPrefix"; + return "mongo.toast.nameInvalid"; +} + function formatCount(n: number): string { if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; @@ -277,9 +301,11 @@ interface CollectionRowProps { dbName: string; collection: Collection; readOnly: boolean; + selected: boolean; /** All callbacks take the entity as arguments so the parent can keep them * `useCallback`-stable — an inline arrow per row would defeat React.memo. */ onOpen: (dbName: string, collectionName: string) => void; + onToggleSelect: (dbName: string, collectionName: string) => void; onRename: (dbName: string, collectionName: string) => void; onSync: (dbName: string, collectionName: string) => void; onDrop: (dbName: string, collectionName: string) => void; @@ -289,7 +315,9 @@ const CollectionRow = React.memo(function CollectionRow({ dbName, collection, readOnly, + selected, onOpen, + onToggleSelect, onRename, onSync, onDrop, @@ -299,6 +327,20 @@ const CollectionRow = React.memo(function CollectionRow({ return (
+ {!readOnly && ( + onToggleSelect(dbName, collection.name)} + aria-label={t("mongo.selectCollection", { name: collection.name })} + className={cn( + "mx-1 size-3 shrink-0 cursor-pointer accent-primary transition-opacity", + selected + ? "opacity-100" + : "opacity-100 md:opacity-0 md:group-focus-within/col:opacity-100 md:group-hover/col:opacity-100" + )} + /> + )} + +
+ )} + void handleRenameCollection()} @@ -753,11 +927,11 @@ function MongoSidebarTree({ dropCollDialog={dropColl} setDropCollDialog={setDropColl} onConfirmDropCollection={() => void handleDropCollection()} - bulkDeleteDialog={NO_BULK} - setBulkDeleteDialog={noop} - selectedCollections={NO_SELECTION} - isBulkDeleting={false} - onConfirmBulkDelete={noop} + bulkDeleteDialog={BULK_STATE[bulkOpen ? "open" : "closed"]} + setBulkDeleteDialog={(s) => setBulkOpen(s.open)} + selectedCollections={selectedKeys} + isBulkDeleting={bulkDeleting} + onConfirmBulkDelete={() => void handleBulkDelete()} /> {monitorOpen && ( diff --git a/apps/desktop-ui/src/components/data-explorer/types.ts b/apps/desktop-ui/src/components/data-explorer/types.ts index a718fc02..ec44d2a0 100644 --- a/apps/desktop-ui/src/components/data-explorer/types.ts +++ b/apps/desktop-ui/src/components/data-explorer/types.ts @@ -79,6 +79,20 @@ export interface SidebarTreeProps { openTab: (req: OpenTabRequest) => void; /** Call after a destructive tree action so the shell refetches connections. */ onConnectionsChanged: () => void; + /** + * Raw text from the shell's single search box, trimmed of nothing. The tree + * filters its own levels (databases, collections, keyspaces…) with it — + * the shell only filters connection *names*, so without this a search can + * never reach inside a tree. Never render a second search input. + */ + searchQuery: string; + /** + * Every saved connection sharing this connection's `sourceId`, including + * this one, with `config` already decrypted. For cross-connection actions + * such as "sync this collection into another server". The shell selects + * them by `sourceId` equality and never inspects the configs. + */ + siblings: UnifiedConnection[]; } export interface PaneProps { diff --git a/apps/desktop-ui/src/components/data-explorer/unified-sidebar.tsx b/apps/desktop-ui/src/components/data-explorer/unified-sidebar.tsx index a552f60f..e3f07700 100644 --- a/apps/desktop-ui/src/components/data-explorer/unified-sidebar.tsx +++ b/apps/desktop-ui/src/components/data-explorer/unified-sidebar.tsx @@ -56,6 +56,10 @@ interface ConnectionRowProps { onEdit: (connection: UnifiedConnection) => void; onDelete: (connection: UnifiedConnection) => void; onConnectionsChanged: () => void; + /** Forwarded to the tree so it can filter its own levels. */ + searchQuery: string; + /** Connections with the same `sourceId`, for cross-connection tree actions. */ + siblings: UnifiedConnection[]; } const ConnectionRow = React.memo(function ConnectionRow({ @@ -66,6 +70,8 @@ const ConnectionRow = React.memo(function ConnectionRow({ onEdit, onDelete, onConnectionsChanged, + searchQuery, + siblings, }: ConnectionRowProps) { const t = useTranslations("DataExplorer"); const adapter = getAdapter(connection.sourceId); @@ -167,6 +173,8 @@ const ConnectionRow = React.memo(function ConnectionRow({ config={connection.config} openTab={openTab} onConnectionsChanged={onConnectionsChanged} + searchQuery={searchQuery} + siblings={siblings} /> )} @@ -178,6 +186,9 @@ const ConnectionRow = React.memo(function ConnectionRow({ /** Bucket for connections whose sourceId has no registered adapter. */ const UNSUPPORTED_GROUP = "__unsupported__"; +/** Stable identity so an empty sibling list never re-renders a memoised row. */ +const NO_SIBLINGS: UnifiedConnection[] = []; + export function UnifiedSidebar({ connections, onOpenTab, @@ -198,8 +209,28 @@ export function UnifiedSidebar({ const safe = connections.filter( (c): c is UnifiedConnection => !!c && typeof c.id === "string" ); - return q ? safe.filter((c) => c.name.toLowerCase().includes(q)) : safe; - }, [connections, query]); + // A search hides connections whose name does not match — except the ones + // the user has expanded. Those keep their row so their tree can filter + // its own databases/collections against the same query, which is how an + // inner match stays reachable. (The legacy sidebar could only match + // inner levels of expanded connections either: collapsed ones have no + // loaded children to match against.) + return q + ? safe.filter((c) => c.name.toLowerCase().includes(q) || expandedIds.has(c.id)) + : safe; + }, [connections, query, expandedIds]); + + /** Connections bucketed by source, so each tree gets its own source's peers. */ + const siblingsBySource = useMemo(() => { + const map = new Map(); + for (const c of connections) { + if (!c || typeof c.id !== "string") continue; + const bucket = map.get(c.sourceId); + if (bucket) bucket.push(c); + else map.set(c.sourceId, [c]); + } + return map; + }, [connections]); const groups = useMemo(() => { const byId = new Map(); @@ -337,6 +368,11 @@ export function UnifiedSidebar({ onEdit={onEditConnection} onDelete={requestDelete} onConnectionsChanged={onConnectionsChanged} + searchQuery={query} + siblings={ + siblingsBySource.get(connection.sourceId) ?? + NO_SIBLINGS + } /> ))} From 330fd7ef4bbaad1f733f1fb2d437a6d250db9f77 Mon Sep 17 00:00:00 2001 From: AKHIL Date: Tue, 4 Aug 2026 23:01:33 +0530 Subject: [PATCH 15/26] feat(data-explorer): add MongoDB pane with full parity Co-Authored-By: Claude Opus 5 (1M context) --- .../data-explorer/adapters/mongodb.tsx | 62 ++++++++++++++++++- 1 file changed, 59 insertions(+), 3 deletions(-) diff --git a/apps/desktop-ui/src/components/data-explorer/adapters/mongodb.tsx b/apps/desktop-ui/src/components/data-explorer/adapters/mongodb.tsx index 514000b8..0bd87db9 100644 --- a/apps/desktop-ui/src/components/data-explorer/adapters/mongodb.tsx +++ b/apps/desktop-ui/src/components/data-explorer/adapters/mongodb.tsx @@ -54,11 +54,13 @@ import { type RenameCollectionState, type RenameDatabaseState, } from "@/components/nosql-explorer/sidebar-dialogs"; +import { DocumentView } from "@/components/nosql-explorer/document-view"; import { GridFsBrowser } from "@/components/nosql-explorer/gridfs-browser"; import { SyncDialog } from "@/components/nosql-explorer/sync-dialog"; import { ServerMonitor } from "@/components/nosql-explorer/server-monitor"; import type { Collection, Database, SavedConnection } from "@/components/nosql-explorer/types"; -import type { ConnectionFormProps, SidebarTreeProps, SourceAdapter } from "../types"; +import { useMongoActions } from "@/lib/data-explorer/mongo-actions"; +import type { ConnectionFormProps, PaneProps, SidebarTreeProps, SourceAdapter } from "../types"; export interface MongoConfig { connectionString: string; @@ -970,6 +972,61 @@ function MongoSidebarTree({ ); } +/* ------------------------------------------------------------------ pane */ + +/** + * Document pane. `DocumentView` already hosts the query builder, aggregation + * pipeline builder, schema view, index manager, explain, import/export and + * codegen, so wiring its props is the whole feature. + * + * A `setState` here is what triggers a refetch — `useMongoActions` refreshes + * from an effect keyed on the tab state, so no handler calls `refresh()` itself + * (the legacy page did, and doing both would double-fetch). + * + * Errors and toasts are owned downstream: mutations sanitise and toast inside + * the hook before re-throwing, and `IndexManager` toasts what `dropIndex` / + * `createIndex` throw and reloads the index list after each mutation. + */ +function MongoPane({ connection, config, state, setState }: PaneProps) { + const readOnly = connection.readOnly ?? false; + const actions = useMongoActions(config, state, readOnly); + + return ( + setState((prev) => ({ ...prev, query, page: 1 }))} + onPageChange={(page) => setState((prev) => ({ ...prev, page }))} + onLimitChange={(limit) => setState((prev) => ({ ...prev, limit, page: 1 }))} + onSortChange={(sortField, sortDirection) => + setState((prev) => ({ ...prev, sortField, sortDirection, page: 1 })) + } + onBulkDelete={actions.bulkDelete} + onImport={actions.importDocuments} + onLoadSchema={actions.loadSchema} + onLoadIndexes={actions.loadIndexes} + onDropIndex={actions.dropIndex} + onCreateIndex={actions.createIndex} + onExplain={actions.explain} + onPreviewPipeline={actions.previewPipeline} + readOnly={readOnly} + /> + ); +} + export const mongodbAdapter: SourceAdapter = { id: "mongodb", label: "MongoDB", @@ -980,6 +1037,5 @@ export const mongodbAdapter: SourceAdapter = { testConnection, ConnectionForm: MongoConnectionForm, SidebarTree: MongoSidebarTree, - // Replaced in Task 11. - Pane: () => null, + Pane: MongoPane, }; From 3dc335af4aaa6435270ddc07891e62fa68349182 Mon Sep 17 00:00:00 2001 From: AKHIL Date: Tue, 4 Aug 2026 23:09:57 +0530 Subject: [PATCH 16/26] fix(data-explorer): confirm single-document delete, key pane per tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MongoDB pane wired onDelete straight to actions.remove, so a row's trash icon deleted a document irreversibly with no prompt — the legacy page's handleDelete only opens an AlertDialog and confirmDelete does the work, and DocumentView confirms bulk delete but not single delete. Restore the dialog in the pane, since document-view.tsx cannot be modified. The shell rendered with no key. Panes are stable component references, so a same-source tab switch reconciled the same instance and carried the previous collection's documents, view mode, selection and index data across. Key it per tab, which also resets the actions hook's document state and covers every other adapter. Co-Authored-By: Claude Opus 5 (1M context) --- apps/desktop-ui/messages/en.json | 2 + .../src/app/app/data-explorer/page.tsx | 5 ++ .../data-explorer/adapters/mongodb.tsx | 67 ++++++++++++++++++- 3 files changed, 73 insertions(+), 1 deletion(-) diff --git a/apps/desktop-ui/messages/en.json b/apps/desktop-ui/messages/en.json index bc7ab1b8..3db544a8 100644 --- a/apps/desktop-ui/messages/en.json +++ b/apps/desktop-ui/messages/en.json @@ -1894,6 +1894,8 @@ "menuDelete": "Delete", "deleteTitle": "Delete connection?", "deleteBody": "{name} and its saved credentials will be removed from this device. This cannot be undone.", + "deleteDocTitle": "Delete document?", + "deleteDocBody": "This document will be permanently removed from the collection. This cannot be undone.", "closeAll": "Close all", "noTabsTitle": "Nothing open", "noTabsBody": "Pick a connection in the sidebar to open a tab.", diff --git a/apps/desktop-ui/src/app/app/data-explorer/page.tsx b/apps/desktop-ui/src/app/app/data-explorer/page.tsx index 77e44917..ccd3f1c5 100644 --- a/apps/desktop-ui/src/app/app/data-explorer/page.tsx +++ b/apps/desktop-ui/src/app/app/data-explorer/page.tsx @@ -233,6 +233,11 @@ export default function DataExplorerPage() { const Pane = adapter.Pane; return ( ) { + const t = useTranslations("DataExplorer"); const readOnly = connection.readOnly ?? false; const actions = useMongoActions(config, state, readOnly); + const [confirmId, setConfirmId] = useState(null); + const [deleting, setDeleting] = useState(false); + + async function confirmDelete() { + if (!confirmId || deleting) return; + setDeleting(true); + try { + await actions.remove(confirmId); + } catch { + // `remove` already toasted the sanitised reason and re-threw only so a + // caller could stay open. Swallowed so the rejection cannot go + // uncaught; like the legacy dialog, this one closes either way. + } finally { + setDeleting(false); + setConfirmId(null); + } + } return ( + <> setConfirmId(id)} onSearch={(query) => setState((prev) => ({ ...prev, query, page: 1 }))} onPageChange={(page) => setState((prev) => ({ ...prev, page }))} onLimitChange={(limit) => setState((prev) => ({ ...prev, limit, page: 1 }))} @@ -1024,6 +1059,36 @@ function MongoPane({ connection, config, state, setState }: PaneProps + + { + if (!open && !deleting) setConfirmId(null); + }} + > + + + {t("deleteDocTitle")} + {t("deleteDocBody")} + + + + {t("connectionDialog.cancel")} + + { + e.preventDefault(); + void confirmDelete(); + }} + > + {t("menuDelete")} + + + + + ); } From e6230f519ee75a918f28c73be0f755d149291cfe Mon Sep 17 00:00:00 2001 From: AKHIL Date: Tue, 4 Aug 2026 23:17:30 +0530 Subject: [PATCH 17/26] feat(data-explorer): add Redis adapter config, form, and registration Co-Authored-By: Claude Opus 5 (1M context) --- apps/desktop-ui/messages/en.json | 19 +- .../__tests__/redis-adapter.test.ts | 64 ++++ .../data-explorer/adapters/redis.tsx | 315 ++++++++++++++++++ .../src/components/data-explorer/sources.ts | 4 +- 4 files changed, 400 insertions(+), 2 deletions(-) create mode 100644 apps/desktop-ui/src/components/data-explorer/__tests__/redis-adapter.test.ts create mode 100644 apps/desktop-ui/src/components/data-explorer/adapters/redis.tsx diff --git a/apps/desktop-ui/messages/en.json b/apps/desktop-ui/messages/en.json index 3db544a8..f0354c8f 100644 --- a/apps/desktop-ui/messages/en.json +++ b/apps/desktop-ui/messages/en.json @@ -1930,7 +1930,24 @@ }, "validation": { "connectionStringRequired": "Enter a connection string.", - "connectionStringScheme": "Connection string must start with mongodb:// or mongodb+srv://." + "connectionStringScheme": "Connection string must start with mongodb:// or mongodb+srv://.", + "redisUrlRequired": "Enter a Redis URL.", + "redisUrlScheme": "Redis URL must start with redis:// or rediss://." + }, + "redis": { + "urlMode": "URL", + "builderMode": "Builder", + "url": "Redis URL", + "urlPlaceholder": "redis://localhost:6379 or rediss://user:pass@host:6380", + "urlHint": "Supports redis:// and rediss:// (TLS). Credentials are encrypted before storage.", + "host": "Host", + "port": "Port", + "username": "Username (ACL)", + "usernamePlaceholder": "default", + "password": "Password", + "dbIndex": "DB index", + "dbIndexPlaceholder": "0", + "tls": "Use TLS (rediss://)" }, "mongo": { "refreshDatabases": "Refresh databases", diff --git a/apps/desktop-ui/src/components/data-explorer/__tests__/redis-adapter.test.ts b/apps/desktop-ui/src/components/data-explorer/__tests__/redis-adapter.test.ts new file mode 100644 index 00000000..9a895044 --- /dev/null +++ b/apps/desktop-ui/src/components/data-explorer/__tests__/redis-adapter.test.ts @@ -0,0 +1,64 @@ +// redis.tsx (via RedisConnectionForm) imports next-intl's useTranslations; +// mock it per this repo's established pattern for test files that +// transitively import a next-intl-consuming component (see +// mongodb-adapter.test.ts and sources.test.ts). +jest.mock("next-intl", () => ({ useTranslations: () => (k: string) => k })) + +import { redisAdapter, buildRedisUrl } from "../adapters/redis"; + +describe("buildRedisUrl", () => { + const base = { host: "", port: "", username: "", password: "", db: "", tls: false }; + + it("defaults host and port", () => { + expect(buildRedisUrl(base)).toBe("redis://localhost:6379"); + }); + + it("switches scheme for TLS", () => { + expect(buildRedisUrl({ ...base, tls: true })).toBe("rediss://localhost:6379"); + }); + + it("percent-encodes credentials and appends the db index", () => { + expect( + buildRedisUrl({ ...base, host: "h", port: "6380", username: "u@x", password: "p:w", db: "3" }) + ).toBe("redis://u%40x:p%3Aw@h:6380/3"); + }); +}); + +describe("redis adapter", () => { + it("starts blank on the conventional local URL", () => { + expect(redisAdapter.blankConfig()).toEqual({ redisUrl: "redis://localhost:6379" }); + }); + + // Assert the exact KEY, not mere truthiness — a truthiness check would + // still pass if the adapter regressed to returning raw English. + it("rejects an empty or non-redis URL with the right i18n keys", () => { + expect(redisAdapter.validate({ redisUrl: "" })).toBe("validation.redisUrlRequired"); + expect(redisAdapter.validate({ redisUrl: "mongodb://localhost:27017" })).toBe( + "validation.redisUrlScheme" + ); + }); + + // Mirror Task 8's guard: a misspelled key renders as a broken path to the + // user, and an exact-match assertion alone would not catch it. + it("returns only keys that resolve in messages/en.json", () => { + const messages = require("../../../../messages/en.json"); + for (const key of ["validation.redisUrlRequired", "validation.redisUrlScheme"]) { + const resolved = key + .split(".") + .reduce((node, part) => (node as Record)?.[part], + messages.DataExplorer); + expect(typeof resolved).toBe("string"); + expect(resolved).not.toBe(""); + } + }); + + it("accepts redis:// and rediss://", () => { + expect(redisAdapter.validate({ redisUrl: "redis://localhost:6379" })).toBeNull(); + expect(redisAdapter.validate({ redisUrl: "rediss://user:pass@host:6380/2" })).toBeNull(); + }); + + it("identifies itself consistently", () => { + expect(redisAdapter.id).toBe("redis"); + expect(redisAdapter.label).toBe("Redis"); + }); +}); diff --git a/apps/desktop-ui/src/components/data-explorer/adapters/redis.tsx b/apps/desktop-ui/src/components/data-explorer/adapters/redis.tsx new file mode 100644 index 00000000..72ad06de --- /dev/null +++ b/apps/desktop-ui/src/components/data-explorer/adapters/redis.tsx @@ -0,0 +1,315 @@ +"use client"; + +import { useState, type FormEvent } from "react"; +import { useTranslations } from "next-intl"; +import { IconBrandRedux, IconX } from "@tabler/icons-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { cn } from "@/lib/utils"; +import { apiFetch } from "@/lib/desktop/api-fetch"; +import { CONNECTION_COLORS } from "@/components/nosql-explorer/connection-form"; +import type { ConnectionFormProps, SourceAdapter } from "../types"; + +export interface RedisConfig { + redisUrl: string; +} + +export interface RedisTabState { + db: number; + selectedKey: string | null; + /** Bumped to force the key browser to refetch. */ + refreshTick: number; +} + +/** Ported verbatim from redis-commander/connection-form.tsx:33-43. */ +export function buildRedisUrl(opts: { + host: string; + port: string; + username: string; + password: string; + db: string; + tls: boolean; +}): string { + const proto = opts.tls ? "rediss" : "redis"; + const auth = + opts.username || opts.password + ? `${encodeURIComponent(opts.username)}:${encodeURIComponent(opts.password)}@` + : ""; + const host = opts.host || "localhost"; + const port = opts.port || "6379"; + const db = opts.db ? `/${opts.db}` : ""; + return `${proto}://${auth}${host}:${port}${db}`; +} + +function blankConfig(): RedisConfig { + return { redisUrl: "redis://localhost:6379" }; +} + +// `validate` returns an i18n KEY under the `DataExplorer` namespace, never +// English prose — the dialog resolves it with `t()`. Contract established in +// Task 8; add these keys to the `DataExplorer.validation` block in en.json. +function validate(config: RedisConfig): string | null { + const value = config.redisUrl.trim(); + if (!value) return "validation.redisUrlRequired"; + if (!/^rediss?:\/\//i.test(value)) return "validation.redisUrlScheme"; + return null; +} + +/** Strip credentials before any message reaches the UI. */ +function sanitizeRedisError(message: string, redisUrl: string): string { + try { + const parsed = new URL(redisUrl); + let out = message; + if (parsed.password) out = out.split(parsed.password).join("***"); + if (parsed.username) out = out.split(parsed.username).join("***"); + return out; + } catch { + return message; + } +} + +async function testConnection(config: RedisConfig): Promise { + const redisUrl = config.redisUrl.trim(); + const res = await apiFetch("/api/redis-commander/connect", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ redisUrl, db: 0 }), + }); + const data = await res.json(); + if (!res.ok || data.error) { + // Real server errors pass through the sanitiser (they are user-facing + // and untranslatable). With nothing reportable, throw an EMPTY message + // — the dialog then renders `t("connectionDialog.testFailed")`. + // Contract established in Task 8. + throw new Error(data.error ? sanitizeRedisError(data.error, redisUrl) : ""); + } +} + +/** + * Controlled-input form: reports values up via `onSubmit`. Never persists, + * never calls the connection service, never toasts — the connection dialog + * owns validate → testConnection → persist and renders `error` inline. + * + * Two-mode layout ported from redis-commander/connection-form.tsx: a raw URL + * input, or a host/port/user/password/db/TLS builder that feeds `buildRedisUrl`. + * Unlike that legacy form, this one never calls the connection service or a + * store directly — it only ever reports `{ redisUrl }` up through `onSubmit`. + */ +function RedisConnectionForm({ initial, saving, error, onSubmit, onCancel }: ConnectionFormProps) { + const t = useTranslations("DataExplorer.connectionDialog"); + const tr = useTranslations("DataExplorer.redis"); + const [name, setName] = useState(initial.name); + const [folder, setFolder] = useState(initial.folder ?? ""); + const [color, setColor] = useState(initial.color ?? null); + const [readOnly, setReadOnly] = useState(initial.readOnly ?? false); + + const [mode, setMode] = useState<"url" | "builder">("url"); + const [redisUrl, setRedisUrl] = useState(initial.config.redisUrl); + + // Builder fields — only assembled into a URL on submit/preview, never + // stored independently, so the URL input stays the single source of truth. + const [host, setHost] = useState("localhost"); + const [port, setPort] = useState("6379"); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [dbIdx, setDbIdx] = useState(""); + const [useTls, setUseTls] = useState(false); + + function handleSubmit(e: FormEvent) { + e.preventDefault(); + const url = + mode === "builder" + ? buildRedisUrl({ host, port, username, password, db: dbIdx, tls: useTls }) + : redisUrl.trim(); + onSubmit({ name, folder, color, readOnly, config: { redisUrl: url } }); + } + + return ( +
+
+ + setName(e.target.value)} + placeholder={t("namePlaceholder")} + disabled={saving} + /> +
+ +
+ + setFolder(e.target.value)} + placeholder={t("folderPlaceholder")} + disabled={saving} + /> +
+ + setMode(v as "url" | "builder")}> + + + {tr("urlMode")} + + + {tr("builderMode")} + + + + + + setRedisUrl(e.target.value)} + placeholder={tr("urlPlaceholder")} + disabled={saving} + className="font-mono text-sm" + /> +

{tr("urlHint")}

+
+ + +
+
+ + setHost(e.target.value)} + disabled={saving} + className="h-8 font-mono text-xs" + /> +
+
+ + setPort(e.target.value)} + disabled={saving} + className="h-8 font-mono text-xs" + /> +
+
+
+
+ + setUsername(e.target.value)} + placeholder={tr("usernamePlaceholder")} + disabled={saving} + className="h-8 font-mono text-xs" + /> +
+
+ + setPassword(e.target.value)} + disabled={saving} + className="h-8 font-mono text-xs" + /> +
+
+
+
+ + setDbIdx(e.target.value)} + placeholder={tr("dbIndexPlaceholder")} + disabled={saving} + className="h-8 font-mono text-xs" + /> +
+ +
+
+
+ +
+ +
+ + {CONNECTION_COLORS.map((c) => ( +
+
+ +
+
+ +

{t("readOnlyHint")}

+
+ +
+ + {error && ( +
+ {error} +
+ )} + +
+ + +
+
+ ); +} + +export const redisAdapter: SourceAdapter = { + id: "redis", + label: "Redis", + icon: IconBrandRedux, + accent: "text-red-500", + blankConfig, + validate, + testConnection, + ConnectionForm: RedisConnectionForm, + // Filled in by Task 13. + SidebarTree: () => null, + // Filled in by Task 13. + Pane: () => null, +}; diff --git a/apps/desktop-ui/src/components/data-explorer/sources.ts b/apps/desktop-ui/src/components/data-explorer/sources.ts index 2ec7fff0..feb78bad 100644 --- a/apps/desktop-ui/src/components/data-explorer/sources.ts +++ b/apps/desktop-ui/src/components/data-explorer/sources.ts @@ -1,4 +1,5 @@ import { mongodbAdapter } from "./adapters/mongodb"; +import { redisAdapter } from "./adapters/redis"; import type { SourceAdapter, SourceId } from "./types"; /** @@ -9,10 +10,11 @@ import type { SourceAdapter, SourceId } from "./types"; // eslint-disable-next-line @typescript-eslint/no-explicit-any export const SOURCES: Record> = { mongodb: mongodbAdapter, + redis: redisAdapter, }; /** Display order in the sidebar and the source picker. */ -export const SOURCE_ORDER: SourceId[] = ["mongodb"]; +export const SOURCE_ORDER: SourceId[] = ["mongodb", "redis"]; /** * Resolve an adapter. Returns null for an unknown id — a connection saved by a From c3f0cf427ac8d0ed75eba6e758a6139629699f46 Mon Sep 17 00:00:00 2001 From: AKHIL Date: Tue, 4 Aug 2026 23:26:47 +0530 Subject: [PATCH 18/26] fix(data-explorer): make sanitizeRedisError fail closed on parse failure sanitizeRedisError returned the raw message when new URL(redisUrl) threw, even though validate() only checks the scheme and lets malformed URLs through (e.g. a non-numeric port). Rebuild it on the existing nosql-error-sanitizer.ts pattern: scrub any redis(s):// URL wholesale by regex, run the result through the shared sanitizeError for generic user:password@ and email patterns, then apply the URL-parse-based credential strip only as a best-effort extra layer that strips both the raw and percent-encoded form of the username/password. A parse failure in that last layer no longer changes whether the message was sanitized. Co-Authored-By: Claude Opus 5 (1M context) --- .../__tests__/redis-adapter.test.ts | 51 ++++++++++++++++++- .../data-explorer/adapters/redis.tsx | 46 ++++++++++++++--- 2 files changed, 89 insertions(+), 8 deletions(-) diff --git a/apps/desktop-ui/src/components/data-explorer/__tests__/redis-adapter.test.ts b/apps/desktop-ui/src/components/data-explorer/__tests__/redis-adapter.test.ts index 9a895044..b7b4a264 100644 --- a/apps/desktop-ui/src/components/data-explorer/__tests__/redis-adapter.test.ts +++ b/apps/desktop-ui/src/components/data-explorer/__tests__/redis-adapter.test.ts @@ -4,7 +4,7 @@ // mongodb-adapter.test.ts and sources.test.ts). jest.mock("next-intl", () => ({ useTranslations: () => (k: string) => k })) -import { redisAdapter, buildRedisUrl } from "../adapters/redis"; +import { redisAdapter, buildRedisUrl, sanitizeRedisError } from "../adapters/redis"; describe("buildRedisUrl", () => { const base = { host: "", port: "", username: "", password: "", db: "", tls: false }; @@ -62,3 +62,52 @@ describe("redis adapter", () => { expect(redisAdapter.label).toBe("Redis"); }); }); + +describe("sanitizeRedisError", () => { + // CRITICAL regression: the old implementation failed OPEN — when + // `new URL(redisUrl)` threw, the catch returned the raw, unsanitized + // message. `validate()` only checks the scheme regex, not full URL + // well-formedness, so a `redisUrl` that clears validation can still + // blow up `new URL()` (e.g. a non-numeric port). + it("still strips credentials when redisUrl fails to parse", () => { + // Passes validate()'s scheme check but is invalid to `new URL()` + // (non-numeric port) — this is the exact fail-open trigger. + expect(() => new URL("redis://host:notaport")).toThrow(); + expect(redisAdapter.validate({ redisUrl: "redis://host:notaport" })).toBeNull(); + + const message = "connect ECONNREFUSED to redis://admin:hunter2@host:notaport"; + const result = sanitizeRedisError(message, "redis://host:notaport"); + expect(result).not.toContain("hunter2"); + expect(result).not.toContain("admin"); + }); + + // A password containing a character that percent-encodes (@) must be + // stripped whichever form (raw or encoded) it shows up in — the old + // implementation only stripped the exact encoded value `new URL()` + // yields, so a driver echoing the raw password would slip through. + it("strips a credential in both its raw and percent-encoded form", () => { + const redisUrl = buildRedisUrl({ + host: "h", port: "6379", username: "u", password: "p@ss", db: "", tls: false, + }); + expect(redisUrl).toBe("redis://u:p%40ss@h:6379"); + + const rawLeak = sanitizeRedisError("auth failed for password p@ss", redisUrl); + expect(rawLeak).not.toContain("p@ss"); + + const encodedLeak = sanitizeRedisError("auth failed for password p%40ss", redisUrl); + expect(encodedLeak).not.toContain("p%40ss"); + }); + + it("strips a plain redis:// URL's username and password from a message", () => { + const message = "connect ECONNREFUSED at redis://user:pass@host:6379"; + const result = sanitizeRedisError(message, "redis://user:pass@host:6379"); + expect(result).not.toContain("user:pass"); + expect(result).not.toContain("user"); + expect(result).not.toContain("pass"); + }); + + it("passes through a message with no URL in it, still readable", () => { + const message = "ECONNREFUSED 127.0.0.1:6379"; + expect(sanitizeRedisError(message, "redis://localhost:6379")).toBe(message); + }); +}); diff --git a/apps/desktop-ui/src/components/data-explorer/adapters/redis.tsx b/apps/desktop-ui/src/components/data-explorer/adapters/redis.tsx index 72ad06de..ead03e8c 100644 --- a/apps/desktop-ui/src/components/data-explorer/adapters/redis.tsx +++ b/apps/desktop-ui/src/components/data-explorer/adapters/redis.tsx @@ -10,6 +10,7 @@ import { Switch } from "@/components/ui/switch"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { cn } from "@/lib/utils"; import { apiFetch } from "@/lib/desktop/api-fetch"; +import { sanitizeError } from "@/lib/nosql-error-sanitizer"; import { CONNECTION_COLORS } from "@/components/nosql-explorer/connection-form"; import type { ConnectionFormProps, SourceAdapter } from "../types"; @@ -58,17 +59,48 @@ function validate(config: RedisConfig): string | null { return null; } -/** Strip credentials before any message reaches the UI. */ -function sanitizeRedisError(message: string, redisUrl: string): string { +function safeDecode(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +/** + * Strip credentials before any message reaches the UI. Fail-CLOSED: every + * path runs the message through regex scrubbing first, so a message survives + * sanitised even when `redisUrl` is too malformed for `new URL()` to parse — + * `validate()` only checks the scheme, not full URL well-formedness, so that + * case is reachable. + */ +export function sanitizeRedisError(message: string, redisUrl: string): string { + // 1. Scrub any redis(s):// URL in the message wholesale — mirrors + // nosql-error-sanitizer's mongodb pattern — so the whole URL never + // survives regardless of what credentials it embeds. + let out = message.replace(/rediss?:\/\/[^/\s]+(\/[^\s]*)*/gi, "redis://***SANITIZED***"); + // 2. Generic `user:password@` and email-style-username scrub shared with + // the Mongo adapter. + out = sanitizeError(out); + + // 3. Best-effort extra layer: strip this connection's specific + // credentials in both their raw and percent-encoded forms, in case one + // leaked into the message outside URL shape (e.g. a driver echoing + // just the password). Additive only — steps 1-2 already sanitised + // `out` unconditionally, so a parse failure here changes nothing. try { const parsed = new URL(redisUrl); - let out = message; - if (parsed.password) out = out.split(parsed.password).join("***"); - if (parsed.username) out = out.split(parsed.username).join("***"); - return out; + for (const encoded of [parsed.username, parsed.password]) { + if (!encoded) continue; + const decoded = safeDecode(encoded); + out = out.split(encoded).join("***").split(decoded).join("***"); + } } catch { - return message; + // redisUrl itself didn't parse — nothing more to strip; `out` is + // already sanitised by steps 1-2. } + + return out; } async function testConnection(config: RedisConfig): Promise { From 575433215eae3f2608d80334d04d4741914b1775 Mon Sep 17 00:00:00 2001 From: AKHIL Date: Tue, 4 Aug 2026 23:36:25 +0530 Subject: [PATCH 19/26] feat(data-explorer): add Redis sidebar tree and pane with full parity Co-Authored-By: Claude Opus 5 (1M context) --- apps/desktop-ui/messages/en.json | 22 +- .../__tests__/redis-adapter.test.ts | 8 + .../data-explorer/__tests__/sources.test.ts | 8 + .../data-explorer/adapters/redis.tsx | 369 +++++++++++++++++- 4 files changed, 399 insertions(+), 8 deletions(-) diff --git a/apps/desktop-ui/messages/en.json b/apps/desktop-ui/messages/en.json index f0354c8f..cbd35058 100644 --- a/apps/desktop-ui/messages/en.json +++ b/apps/desktop-ui/messages/en.json @@ -1947,7 +1947,27 @@ "password": "Password", "dbIndex": "DB index", "dbIndexPlaceholder": "0", - "tls": "Use TLS (rediss://)" + "tls": "Use TLS (rediss://)", + "dbLabel": "db {index}", + "noMatches": "No matching databases", + "db": "DB", + "selectDatabase": "Select database (0-15)", + "tabKeyEditor": "Key Editor", + "tabConsole": "Console", + "tabServer": "Server", + "tabPubSub": "Pub/Sub", + "tabMonitor": "Monitor", + "tabScanner": "Scanner", + "tabSearch": "Search", + "flush": "Flush", + "flushTitle": "Flush Keys", + "flushDescription": "Enter a pattern to delete matching keys, or leave blank to flush the entire database (requires DANGER_ZONE=true).", + "flushPatternLabel": "Pattern (optional)", + "flushPatternPlaceholder": "e.g. session:* — blank = FLUSHDB", + "flushing": "Flushing…", + "flushedAll": "Database flushed", + "flushedKeys": "{count, plural, one {# key deleted} other {# keys deleted}}", + "flushFailed": "Flush failed" }, "mongo": { "refreshDatabases": "Refresh databases", diff --git a/apps/desktop-ui/src/components/data-explorer/__tests__/redis-adapter.test.ts b/apps/desktop-ui/src/components/data-explorer/__tests__/redis-adapter.test.ts index b7b4a264..07012d18 100644 --- a/apps/desktop-ui/src/components/data-explorer/__tests__/redis-adapter.test.ts +++ b/apps/desktop-ui/src/components/data-explorer/__tests__/redis-adapter.test.ts @@ -3,6 +3,14 @@ // transitively import a next-intl-consuming component (see // mongodb-adapter.test.ts and sources.test.ts). jest.mock("next-intl", () => ({ useTranslations: () => (k: string) => k })) +// redis.tsx's pane imports @/components/ui/resizable, whose react-resizable-panels +// build ships as ESM and cannot be parsed under this node-environment jest +// config. These logic tests never render the pane, so a stub is enough. +jest.mock("react-resizable-panels", () => ({ + Panel: () => null, + PanelGroup: () => null, + PanelResizeHandle: () => null, +})) import { redisAdapter, buildRedisUrl, sanitizeRedisError } from "../adapters/redis"; diff --git a/apps/desktop-ui/src/components/data-explorer/__tests__/sources.test.ts b/apps/desktop-ui/src/components/data-explorer/__tests__/sources.test.ts index d207dd59..127e7a29 100644 --- a/apps/desktop-ui/src/components/data-explorer/__tests__/sources.test.ts +++ b/apps/desktop-ui/src/components/data-explorer/__tests__/sources.test.ts @@ -3,6 +3,14 @@ // transitively import a next-intl-consuming component (see // api-client/__tests__/collections-context.test.ts). jest.mock("next-intl", () => ({ useTranslations: () => (k: string) => k })) +// The Redis adapter's pane imports @/components/ui/resizable, whose +// react-resizable-panels build ships as ESM and cannot be parsed under this +// node-environment jest config. The registry tests never render, so stub it. +jest.mock("react-resizable-panels", () => ({ + Panel: () => null, + PanelGroup: () => null, + PanelResizeHandle: () => null, +})) import { SOURCES, SOURCE_ORDER, getAdapter } from "../sources"; diff --git a/apps/desktop-ui/src/components/data-explorer/adapters/redis.tsx b/apps/desktop-ui/src/components/data-explorer/adapters/redis.tsx index ead03e8c..54a2bacf 100644 --- a/apps/desktop-ui/src/components/data-explorer/adapters/redis.tsx +++ b/apps/desktop-ui/src/components/data-explorer/adapters/redis.tsx @@ -1,18 +1,51 @@ "use client"; -import { useState, type FormEvent } from "react"; +import React, { useCallback, useState, type FormEvent } from "react"; import { useTranslations } from "next-intl"; -import { IconBrandRedux, IconX } from "@tabler/icons-react"; +import { toast } from "sonner"; +import { + IconActivity, + IconBrandRedux, + IconBroadcast, + IconChartBar, + IconDatabase, + IconEye, + IconSearch, + IconTerminal2, + IconTrash, + IconX, +} from "@tabler/icons-react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; +import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { cn } from "@/lib/utils"; import { apiFetch } from "@/lib/desktop/api-fetch"; import { sanitizeError } from "@/lib/nosql-error-sanitizer"; import { CONNECTION_COLORS } from "@/components/nosql-explorer/connection-form"; -import type { ConnectionFormProps, SourceAdapter } from "../types"; +import { KeyBrowser } from "@/components/redis-commander/key-browser"; +import { ValueEditor } from "@/components/redis-commander/value-editor"; +import { CommandPanel } from "@/components/redis-commander/command-panel"; +import { ServerDashboard } from "@/components/redis-commander/server-dashboard"; +import { MetricsPane } from "@/components/redis-commander/metrics-pane"; +import { BulkActions } from "@/components/redis-commander/bulk-actions"; +import { PubSubPane } from "@/components/redis-commander/pubsub-pane"; +import { MonitorPane } from "@/components/redis-commander/monitor-pane"; +import { ScannerPane } from "@/components/redis-commander/scanner-pane"; +import { SearchWorkbench } from "@/components/redis-commander/search-workbench"; +import type { ConnectionFormProps, PaneProps, SidebarTreeProps, SourceAdapter } from "../types"; export interface RedisConfig { redisUrl: string; @@ -331,6 +364,330 @@ function RedisConnectionForm({ initial, saving, error, onSubmit, onCancel }: Con ); } +/* ------------------------------------------------------------------ tree */ + +/** Redis has a fixed 16 logical databases; there is nothing to fetch. */ +const DB_INDEXES = Array.from({ length: 16 }, (_, i) => i); + +interface DbRowProps { + index: number; + label: string; + /** Takes the index so the parent can keep one `useCallback`-stable handler — + * an inline arrow per row would defeat React.memo. */ + onOpen: (index: number) => void; +} + +const DbRow = React.memo(function DbRow({ index, label, onOpen }: DbRowProps) { + return ( + + ); +}); + +function RedisSidebarTree({ + connection, + openTab, + searchQuery, +}: SidebarTreeProps) { + const t = useTranslations("DataExplorer"); + + const openDb = useCallback( + (index: number) => { + openTab({ + key: `db${index}`, + title: `db ${index}`, + subtitle: connection.name, + state: { db: index, selectedKey: null, refreshTick: 0 }, + }); + }, + [openTab, connection.name] + ); + + // Search comes from the shell's one box (never a second input here). A + // connection whose own name matches shows all 16; otherwise the rows filter + // on their own labels — same rule as the Mongo tree. + const q = searchQuery.trim().toLowerCase(); + const showAll = !q || connection.name.toLowerCase().includes(q); + const rows = DB_INDEXES.map((index) => ({ index, label: t("redis.dbLabel", { index }) })).filter( + (row) => showAll || row.label.toLowerCase().includes(q) + ); + + if (rows.length === 0) { + return

{t("redis.noMatches")}

; + } + + return ( +
+ {rows.map((row) => ( + + ))} +
+ ); +} + +/* ------------------------------------------------------------------ pane */ + +const INNER_TAB = + "h-8 rounded-none border-r px-3 text-xs data-[state=active]:bg-accent data-[state=active]:shadow-none"; + +/** + * Key-browsing workbench, mirroring `redis-commander/page.tsx:384-505`. + * + * Every imported pane takes `{ redisUrl, db }` and fetches for itself, so this + * is composition: the only shared state is the selected key (browser → + * editor), the db index, and a refresh tick that remounts `KeyBrowser`. + * + * `ValueEditor`'s `onKeyDeleted` / `onKeyRenamed` fire *after* the mutation has + * already succeeded inside the editor (value-editor.tsx:172,198) — they are + * notifications, not actions, so reacting to them is safe. + * + * Known and inherited: the imported panes carry hardcoded English. Only the + * chrome written here is translated. + */ +function RedisPane({ connection, config, tab, state, setState }: PaneProps) { + const t = useTranslations("DataExplorer"); + const readOnly = !!connection.readOnly; + // Reported up by KeyBrowser and rendered back into it; pane-local because + // nothing else needs it and it must not survive a tab restore. + const [dbSize, setDbSize] = useState(0); + const [flushOpen, setFlushOpen] = useState(false); + const [flushPattern, setFlushPattern] = useState(""); + const [flushing, setFlushing] = useState(false); + + const bumpRefresh = () => setState((prev) => ({ ...prev, refreshTick: prev.refreshTick + 1 })); + const selectKey = (key: string) => setState((prev) => ({ ...prev, selectedKey: key })); + + /** Ported from redis-commander/page.tsx:146-176. Errors are sanitised — + * a Redis URL can carry a username and password. */ + async function handleFlush() { + setFlushing(true); + try { + const res = await apiFetch("/api/redis-commander/flush", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + redisUrl: config.redisUrl, + db: state.db, + pattern: flushPattern.trim() || undefined, + }), + }); + const data = (await res.json().catch(() => ({}))) as { deleted?: number; error?: string }; + // The legacy page only checked `data.error`; a non-OK response with + // no error body silently read as success. `!res.ok` closes that. + if (!res.ok || data.error) throw new Error(data.error ?? ""); + // `deleted === -1` is the FLUSHDB (no pattern) reply — a count is + // meaningless there. + toast.success( + data.deleted === -1 + ? t("redis.flushedAll") + : t("redis.flushedKeys", { count: data.deleted ?? 0 }) + ); + setState((prev) => ({ + ...prev, + selectedKey: null, + refreshTick: prev.refreshTick + 1, + })); + } catch (err) { + const raw = err instanceof Error ? err.message : ""; + toast.error(raw ? sanitizeRedisError(raw, config.redisUrl) : t("redis.flushFailed")); + } finally { + setFlushing(false); + setFlushOpen(false); + setFlushPattern(""); + } + } + + return ( +
+
+ + {!readOnly && ( + <> + + + + )} +
+ +
+ + + + + + + + + + + {t("redis.tabKeyEditor")} + + + + {t("redis.tabConsole")} + + + + {t("redis.tabServer")} + + + + {t("redis.tabPubSub")} + + + + {t("redis.tabMonitor")} + + + + {t("redis.tabScanner")} + + + + {t("redis.tabSearch")} + + + + + setState((prev) => ({ + ...prev, + selectedKey: prev.selectedKey === k ? null : prev.selectedKey, + refreshTick: prev.refreshTick + 1, + })) + } + onKeyRenamed={(oldKey, newKey) => + setState((prev) => + prev.selectedKey === oldKey + ? { ...prev, selectedKey: newKey } + : prev + ) + } + onRefreshKeys={bumpRefresh} + /> + + + + + +
+ +
+ +
+ + + + + + + + + + + + +
+
+
+
+ + { if (!flushing) setFlushOpen(open); }}> + + + {t("redis.flushTitle")} + {t("redis.flushDescription")} + +
+ + setFlushPattern(e.target.value)} + /> +
+ + + {t("connectionDialog.cancel")} + + { + e.preventDefault(); + void handleFlush(); + }} + disabled={flushing} + > + {flushing ? t("redis.flushing") : t("redis.flush")} + + +
+
+
+ ); +} + export const redisAdapter: SourceAdapter = { id: "redis", label: "Redis", @@ -340,8 +697,6 @@ export const redisAdapter: SourceAdapter = { validate, testConnection, ConnectionForm: RedisConnectionForm, - // Filled in by Task 13. - SidebarTree: () => null, - // Filled in by Task 13. - Pane: () => null, + SidebarTree: RedisSidebarTree, + Pane: RedisPane, }; From 016b42f6da60372e594dffe1d59d81d59303e47e Mon Sep 17 00:00:00 2001 From: AKHIL Date: Tue, 4 Aug 2026 23:46:40 +0530 Subject: [PATCH 20/26] fix(data-explorer): drop in-pane db selector and Redis read-only switch The pane's db selector wrote state.db while the tab's shell-owned id and title stayed frozen at open time, so a switched tab mislabelled its database and a second tab could open on the same one. Database switching is now a sidebar-tree-only action, which always opens or focuses the correctly identified tab. The connection form no longer offers a read-only switch: ValueEditor and CommandPanel take no readOnly prop, so the flag could only ever hide Flush and BulkActions, not "block every write operation" as the hint promises. The field stays in the data model and keeps gating those two controls. Co-Authored-By: Claude Opus 5 (1M context) --- apps/desktop-ui/messages/en.json | 2 - .../data-explorer/adapters/redis.tsx | 78 ++++++++----------- 2 files changed, 31 insertions(+), 49 deletions(-) diff --git a/apps/desktop-ui/messages/en.json b/apps/desktop-ui/messages/en.json index cbd35058..9cc5e83e 100644 --- a/apps/desktop-ui/messages/en.json +++ b/apps/desktop-ui/messages/en.json @@ -1950,8 +1950,6 @@ "tls": "Use TLS (rediss://)", "dbLabel": "db {index}", "noMatches": "No matching databases", - "db": "DB", - "selectDatabase": "Select database (0-15)", "tabKeyEditor": "Key Editor", "tabConsole": "Console", "tabServer": "Server", diff --git a/apps/desktop-ui/src/components/data-explorer/adapters/redis.tsx b/apps/desktop-ui/src/components/data-explorer/adapters/redis.tsx index 54a2bacf..0f30c6f6 100644 --- a/apps/desktop-ui/src/components/data-explorer/adapters/redis.tsx +++ b/apps/desktop-ui/src/components/data-explorer/adapters/redis.tsx @@ -28,7 +28,6 @@ import { import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { Switch } from "@/components/ui/switch"; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { cn } from "@/lib/utils"; @@ -169,7 +168,6 @@ function RedisConnectionForm({ initial, saving, error, onSubmit, onCancel }: Con const [name, setName] = useState(initial.name); const [folder, setFolder] = useState(initial.folder ?? ""); const [color, setColor] = useState(initial.color ?? null); - const [readOnly, setReadOnly] = useState(initial.readOnly ?? false); const [mode, setMode] = useState<"url" | "builder">("url"); const [redisUrl, setRedisUrl] = useState(initial.config.redisUrl); @@ -189,7 +187,9 @@ function RedisConnectionForm({ initial, saving, error, onSubmit, onCancel }: Con mode === "builder" ? buildRedisUrl({ host, port, username, password, db: dbIdx, tls: useTls }) : redisUrl.trim(); - onSubmit({ name, folder, color, readOnly, config: { redisUrl: url } }); + // `readOnly` is passed through untouched, never edited here — see the + // note where the switch used to be. + onSubmit({ name, folder, color, readOnly: initial.readOnly, config: { redisUrl: url } }); } return ( @@ -338,13 +338,16 @@ function RedisConnectionForm({ initial, saving, error, onSubmit, onCancel }: Con -
-
- -

{t("readOnlyHint")}

-
- -
+ {/* No read-only switch for Redis. `connectionDialog.readOnlyHint` + promises "blocks every write operation", and this adapter cannot + keep that promise: ValueEditor (save/delete/rename/copy) and + CommandPanel (arbitrary DEL / FLUSHALL) live in + components/redis-commander/ and accept no `readOnly` prop, so the + flag would only hide Flush and BulkActions. The field itself stays + in the data model — an already-read-only connection keeps that + partial gating in the pane. Restore this switch when + value-editor.tsx, command-panel.tsx and bulk-actions.tsx all + accept a `readOnly` prop. */} {error && (
@@ -506,44 +509,25 @@ function RedisPane({ connection, config, tab, state, setState }: PaneProps -
- - {!readOnly && ( - <> - - - - )} -
+ + {t("redis.flush")} + +
+ )}
From 8ad42797184b6b88f64c9b180468ff705fb6805f Mon Sep 17 00:00:00 2001 From: AKHIL Date: Tue, 4 Aug 2026 23:56:00 +0530 Subject: [PATCH 21/26] feat(data-explorer): add legacy connection import Co-Authored-By: Claude Opus 5 (1M context) --- apps/desktop-ui/messages/en.json | 14 +- .../src/app/app/data-explorer/page.tsx | 8 +- .../data-explorer/import-legacy-dialog.tsx | 190 ++++++++++++++++++ .../data-explorer-legacy-import.test.ts | 65 ++++++ .../src/lib/data-explorer/legacy-import.ts | 67 ++++++ 5 files changed, 342 insertions(+), 2 deletions(-) create mode 100644 apps/desktop-ui/src/components/data-explorer/import-legacy-dialog.tsx create mode 100644 apps/desktop-ui/src/lib/__tests__/data-explorer-legacy-import.test.ts create mode 100644 apps/desktop-ui/src/lib/data-explorer/legacy-import.ts diff --git a/apps/desktop-ui/messages/en.json b/apps/desktop-ui/messages/en.json index 9cc5e83e..fc3d7001 100644 --- a/apps/desktop-ui/messages/en.json +++ b/apps/desktop-ui/messages/en.json @@ -1919,6 +1919,16 @@ "save": "Save", "cancel": "Cancel" }, + "importDialog": { + "title": "Import from existing tools", + "loading": "Looking for connections in Database Explorer and Redis Commander…", + "empty": "No connections found in Database Explorer or Redis Commander.", + "allSkipped": "{count, plural, one {# connection has} other {# connections have}} already been imported. Nothing new to bring in.", + "previewHeading": "{count, plural, one {# connection} other {# connections}} will be imported:", + "skippedNote": "{count, plural, one {# connection was} other {# connections were}} already imported and will be skipped.", + "importing": "Importing…", + "confirm": "Import {count, plural, one {# connection} other {# connections}}" + }, "toast": { "saved": "Connection saved", "deleted": "Connection deleted", @@ -1926,7 +1936,9 @@ "deleteFailed": "Could not delete the connection", "loadFailed": "Could not load connections", "fetchFailed": "Failed to fetch documents for {collection}", - "docDeleted": "Document deleted" + "docDeleted": "Document deleted", + "legacyImportFailed": "Could not read existing connections from Database Explorer or Redis Commander", + "legacyImportResult": "{imported, plural, =0 {No new connections imported} one {Imported # connection} other {Imported # connections}}{skipped, plural, =0 {} other {, # already imported}}{failed, plural, =0 {} other {, # failed}}" }, "validation": { "connectionStringRequired": "Enter a connection string.", diff --git a/apps/desktop-ui/src/app/app/data-explorer/page.tsx b/apps/desktop-ui/src/app/app/data-explorer/page.tsx index ccd3f1c5..b56c865f 100644 --- a/apps/desktop-ui/src/app/app/data-explorer/page.tsx +++ b/apps/desktop-ui/src/app/app/data-explorer/page.tsx @@ -14,6 +14,7 @@ import { VaultLockedPlaceholder } from "@/components/vault-locked-placeholder"; import { VaultRestoringSkeleton } from "@/components/vault-restoring-skeleton"; import { UnifiedTabBar } from "@/components/data-explorer/unified-tab-bar"; import { ConnectionDialog } from "@/components/data-explorer/connection-dialog"; +import { ImportLegacyDialog } from "@/components/data-explorer/import-legacy-dialog"; import { UnifiedSidebar } from "@/components/data-explorer/unified-sidebar"; import { listConnections, touchConnection } from "@/components/data-explorer/connection-service"; import { getAdapter } from "@/components/data-explorer/sources"; @@ -37,7 +38,6 @@ export default function DataExplorerPage() { const [isInitialized, setIsInitialized] = useState(false); const [connectionDialogOpen, setConnectionDialogOpen] = useState(false); const [editingConnection, setEditingConnection] = useState(null); - // Consumed by the legacy-import dialog in Task 14. const [importDialogOpen, setImportDialogOpen] = useState(false); /** True once a connection fetch has *succeeded*. Gates tab pruning. */ const [connectionsLoaded, setConnectionsLoaded] = useState(false); @@ -280,6 +280,12 @@ export default function DataExplorerPage() { editing={editingConnection} onSaved={() => void reloadConnections()} /> + void reloadConnections()} + />
); } diff --git a/apps/desktop-ui/src/components/data-explorer/import-legacy-dialog.tsx b/apps/desktop-ui/src/components/data-explorer/import-legacy-dialog.tsx new file mode 100644 index 00000000..a686a0ef --- /dev/null +++ b/apps/desktop-ui/src/components/data-explorer/import-legacy-dialog.tsx @@ -0,0 +1,190 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; +import { toast } from "sonner"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import useAuth from "@/utils/useAuth"; +import { useMasterKeyStore } from "@/store/master-key-store"; +import { getConnections as getMongoConnections } from "@/components/nosql-explorer/connection-service"; +import { getConnections as getRedisConnections } from "@/components/redis-commander/connection-service"; +import { + dedupeAgainstExisting, + legacyMongoToUnified, + legacyRedisToUnified, + type LegacyCandidate, +} from "@/lib/data-explorer/legacy-import"; +import { SOURCES } from "./sources"; +import { saveConnection } from "./connection-service"; +import type { UnifiedConnection } from "./types"; + +interface ImportLegacyDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** Current unified connections, used to dedupe by (sourceId, name). */ + existing: UnifiedConnection[]; + onImported: () => void; +} + +/** + * One-shot, opt-in copy of connections from Database Explorer and Redis + * Commander into the unified store. Both legacy stores are read-only here — + * this never writes to or deletes from them, so the old tools keep working + * unchanged. Re-running it is safe: `dedupeAgainstExisting` skips anything + * already imported by (sourceId, name). + */ +export function ImportLegacyDialog({ open, onOpenChange, existing, onImported }: ImportLegacyDialogProps) { + const t = useTranslations("DataExplorer"); + const { user } = useAuth(); + const { encryptionKey } = useMasterKeyStore(); + + const [loading, setLoading] = useState(false); + const [candidates, setCandidates] = useState([]); + const [skipped, setSkipped] = useState(0); + const [importing, setImporting] = useState(false); + + useEffect(() => { + if (!open) return; + setCandidates([]); + setSkipped(0); + if (!user || !encryptionKey) return; + + let cancelled = false; + setLoading(true); + + (async () => { + try { + const [mongoRows, redisRows] = await Promise.all([ + getMongoConnections(user.uid, encryptionKey), + getRedisConnections(encryptionKey), + ]); + + const mongoCandidates = mongoRows.map((row) => + legacyMongoToUnified( + { name: row.name, color: row.color, readOnly: row.readOnly, dbType: row.dbType }, + row.connectionString + ) + ); + const redisCandidates = redisRows + .filter((row) => !!row.config) + .map((row) => + legacyRedisToUnified( + { name: row.name, folder: row.folder }, + row.config as { redisUrl: string; folder?: string } + ) + ); + + const { toImport, skipped: skippedCount } = dedupeAgainstExisting( + [...mongoCandidates, ...redisCandidates], + existing + ); + if (cancelled) return; + setCandidates(toImport); + setSkipped(skippedCount); + } catch (e) { + if (cancelled) return; + toast.error(e instanceof Error ? e.message : t("toast.legacyImportFailed")); + onOpenChange(false); + } finally { + if (!cancelled) setLoading(false); + } + })(); + + return () => { + cancelled = true; + }; + // Re-run only when the dialog opens or its inputs change identity — + // not on every `existing` array reference from a parent re-render. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, user, encryptionKey]); + + async function handleConfirm() { + if (!encryptionKey || candidates.length === 0) return; + setImporting(true); + let imported = 0; + let failed = 0; + // Per-row failure must not abort the batch — collect and report the count. + for (const candidate of candidates) { + try { + await saveConnection(candidate.sourceId, candidate.values, encryptionKey); + imported++; + } catch { + failed++; + } + } + setImporting(false); + toast.success(t("toast.legacyImportResult", { imported, skipped, failed })); + onImported(); + onOpenChange(false); + } + + return ( + !importing && onOpenChange(next)}> + + + {t("importDialog.title")} + + + {loading ? ( +

+ {t("importDialog.loading")} +

+ ) : candidates.length === 0 ? ( +

+ {skipped > 0 + ? t("importDialog.allSkipped", { count: skipped }) + : t("importDialog.empty")} +

+ ) : ( + <> +

+ {t("importDialog.previewHeading", { count: candidates.length })} +

+ +
    + {candidates.map((candidate, i) => ( +
  • + {candidate.values.name} + + {SOURCES[candidate.sourceId].label} + +
  • + ))} +
+
+ {skipped > 0 && ( +

+ {t("importDialog.skippedNote", { count: skipped })} +

+ )} + + )} + + + + + +
+
+ ); +} diff --git a/apps/desktop-ui/src/lib/__tests__/data-explorer-legacy-import.test.ts b/apps/desktop-ui/src/lib/__tests__/data-explorer-legacy-import.test.ts new file mode 100644 index 00000000..b4de198d --- /dev/null +++ b/apps/desktop-ui/src/lib/__tests__/data-explorer-legacy-import.test.ts @@ -0,0 +1,65 @@ +import { + legacyMongoToUnified, + legacyRedisToUnified, + dedupeAgainstExisting, +} from "../data-explorer/legacy-import"; +import type { UnifiedConnection } from "@/components/data-explorer/types"; + +describe("legacyMongoToUnified", () => { + it("wraps the bare connection string into a JSON config", () => { + const result = legacyMongoToUnified( + { name: "local", color: "#ef4444", readOnly: true, dbType: "mongodb" }, + "mongodb://localhost:27017" + ); + expect(result.sourceId).toBe("mongodb"); + expect(result.values.config).toEqual({ + connectionString: "mongodb://localhost:27017", + dbType: "mongodb", + }); + expect(result.values.name).toBe("local"); + expect(result.values.color).toBe("#ef4444"); + expect(result.values.readOnly).toBe(true); + }); + + it("infers dbType for rows saved before the field existed", () => { + const result = legacyMongoToUnified( + { name: "cosmos" }, + "mongodb://acct:key@acct.mongo.cosmos.azure.com:10255/?ssl=true" + ); + expect((result.values.config as { dbType: string }).dbType).toBe("cosmosdb"); + }); +}); + +describe("legacyRedisToUnified", () => { + it("lifts folder out of the encrypted blob to the top level", () => { + const result = legacyRedisToUnified( + { name: "cache" }, + { redisUrl: "redis://localhost:6379", folder: "prod" } + ); + expect(result.sourceId).toBe("redis"); + expect(result.values.config).toEqual({ redisUrl: "redis://localhost:6379" }); + expect(result.values.folder).toBe("prod"); + }); +}); + +describe("dedupeAgainstExisting", () => { + const existing = [ + { id: "1", sourceId: "redis", name: "cache" } as unknown as UnifiedConnection, + ]; + + it("skips a candidate matching an existing (sourceId, name) pair", () => { + const dupe = legacyRedisToUnified({ name: "cache" }, { redisUrl: "redis://localhost:6379" }); + const fresh = legacyRedisToUnified({ name: "other" }, { redisUrl: "redis://localhost:6379" }); + const { toImport, skipped } = dedupeAgainstExisting([dupe, fresh], existing); + expect(skipped).toBe(1); + expect(toImport).toHaveLength(1); + expect(toImport[0].values.name).toBe("other"); + }); + + it("does not treat the same name under a different source as a duplicate", () => { + const mongo = legacyMongoToUnified({ name: "cache" }, "mongodb://localhost:27017"); + const { toImport, skipped } = dedupeAgainstExisting([mongo], existing); + expect(skipped).toBe(0); + expect(toImport).toHaveLength(1); + }); +}); diff --git a/apps/desktop-ui/src/lib/data-explorer/legacy-import.ts b/apps/desktop-ui/src/lib/data-explorer/legacy-import.ts new file mode 100644 index 00000000..f5a8775f --- /dev/null +++ b/apps/desktop-ui/src/lib/data-explorer/legacy-import.ts @@ -0,0 +1,67 @@ +import { detectDbType, type DbType } from "@/lib/nosql-dialects"; +import type { ConnectionFormValues, UnifiedConnection } from "@/components/data-explorer/types"; +import type { MongoConfig } from "@/components/data-explorer/adapters/mongodb"; +import type { RedisConfig } from "@/components/data-explorer/adapters/redis"; + +export interface LegacyCandidate { + sourceId: "mongodb" | "redis"; + values: ConnectionFormValues; +} + +/** + * The legacy Mongo store encrypts a bare connection string; the unified store + * always encrypts JSON. `dbType` is absent on rows saved before that field + * existed — infer it from the host. + */ +export function legacyMongoToUnified( + row: { name: string; color?: string | null; readOnly?: boolean; dbType?: DbType }, + connectionString: string +): LegacyCandidate { + return { + sourceId: "mongodb", + values: { + name: row.name, + folder: "", + color: row.color ?? null, + readOnly: row.readOnly ?? false, + config: { connectionString, dbType: row.dbType ?? detectDbType(connectionString) }, + }, + }; +} + +/** Legacy Redis kept `folder` inside the encrypted blob; it is top-level now. */ +export function legacyRedisToUnified( + row: { name: string; folder?: string }, + config: { redisUrl: string; folder?: string } +): LegacyCandidate { + return { + sourceId: "redis", + values: { + name: row.name, + folder: config.folder ?? row.folder ?? "", + color: null, + readOnly: false, + config: { redisUrl: config.redisUrl }, + }, + }; +} + +/** Re-import is safe: an already-imported (sourceId, name) pair is skipped. */ +export function dedupeAgainstExisting( + candidates: LegacyCandidate[], + existing: UnifiedConnection[] +): { toImport: LegacyCandidate[]; skipped: number } { + const seen = new Set(existing.map((c) => `${c.sourceId}:${c.name}`)); + const toImport: LegacyCandidate[] = []; + let skipped = 0; + for (const candidate of candidates) { + const key = `${candidate.sourceId}:${candidate.values.name}`; + if (seen.has(key)) { + skipped++; + continue; + } + seen.add(key); + toImport.push(candidate); + } + return { toImport, skipped }; +} From 820b2c17598ce6114efccd148741aecb07514ce2 Mon Sep 17 00:00:00 2001 From: AKHIL Date: Wed, 5 Aug 2026 00:06:34 +0530 Subject: [PATCH 22/26] fix(data-explorer): dedupe legacy imports by config, gate on loaded state Review findings: dedupe key was (sourceId, name) only, silently dropping distinct connections sharing a name (legacy Mongo defaults unnamed rows to "My Connection"); fold in a config discriminator (connectionString/redisUrl) so same-named-different-config rows both import while true duplicates still dedupe to zero, including across a name-only case/whitespace difference. Gate the import on the shell's connectionsLoaded flag so a failed or in-flight unified-list fetch can't make every legacy connection look new. Drop the raw error message from the fetch-failure toast (untranslated, unnecessary risk in a credential-handling flow) and report total failure (imported === 0, failed > 0) as toast.error instead of toast.success. Co-Authored-By: Claude Opus 5 (1M context) --- apps/desktop-ui/messages/en.json | 1 + .../src/app/app/data-explorer/page.tsx | 1 + .../data-explorer/import-legacy-dialog.tsx | 49 ++++++++--- .../data-explorer-legacy-import.test.ts | 81 ++++++++++++++++++- .../src/lib/data-explorer/legacy-import.ts | 35 +++++++- 5 files changed, 153 insertions(+), 14 deletions(-) diff --git a/apps/desktop-ui/messages/en.json b/apps/desktop-ui/messages/en.json index fc3d7001..5f6f9488 100644 --- a/apps/desktop-ui/messages/en.json +++ b/apps/desktop-ui/messages/en.json @@ -1922,6 +1922,7 @@ "importDialog": { "title": "Import from existing tools", "loading": "Looking for connections in Database Explorer and Redis Commander…", + "notReady": "Still loading your existing connections — try again in a moment.", "empty": "No connections found in Database Explorer or Redis Commander.", "allSkipped": "{count, plural, one {# connection has} other {# connections have}} already been imported. Nothing new to bring in.", "previewHeading": "{count, plural, one {# connection} other {# connections}} will be imported:", diff --git a/apps/desktop-ui/src/app/app/data-explorer/page.tsx b/apps/desktop-ui/src/app/app/data-explorer/page.tsx index b56c865f..c49be2f9 100644 --- a/apps/desktop-ui/src/app/app/data-explorer/page.tsx +++ b/apps/desktop-ui/src/app/app/data-explorer/page.tsx @@ -284,6 +284,7 @@ export default function DataExplorerPage() { open={importDialogOpen} onOpenChange={setImportDialogOpen} existing={connections} + connectionsLoaded={connectionsLoaded} onImported={() => void reloadConnections()} /> diff --git a/apps/desktop-ui/src/components/data-explorer/import-legacy-dialog.tsx b/apps/desktop-ui/src/components/data-explorer/import-legacy-dialog.tsx index a686a0ef..96926695 100644 --- a/apps/desktop-ui/src/components/data-explorer/import-legacy-dialog.tsx +++ b/apps/desktop-ui/src/components/data-explorer/import-legacy-dialog.tsx @@ -29,8 +29,16 @@ import type { UnifiedConnection } from "./types"; interface ImportLegacyDialogProps { open: boolean; onOpenChange: (open: boolean) => void; - /** Current unified connections, used to dedupe by (sourceId, name). */ + /** Current unified connections, used to dedupe by (sourceId, name, config). */ existing: UnifiedConnection[]; + /** + * True once the shell's own connection list has genuinely finished + * loading (see `page.tsx`'s `connectionsLoaded`). A failed or in-flight + * load leaves `existing` stale or empty — deduping against that would + * silently re-import connections the user already has, so the dialog + * refuses to fetch legacy candidates until this is true. + */ + connectionsLoaded: boolean; onImported: () => void; } @@ -39,9 +47,15 @@ interface ImportLegacyDialogProps { * Commander into the unified store. Both legacy stores are read-only here — * this never writes to or deletes from them, so the old tools keep working * unchanged. Re-running it is safe: `dedupeAgainstExisting` skips anything - * already imported by (sourceId, name). + * already imported by (sourceId, name, config). */ -export function ImportLegacyDialog({ open, onOpenChange, existing, onImported }: ImportLegacyDialogProps) { +export function ImportLegacyDialog({ + open, + onOpenChange, + existing, + connectionsLoaded, + onImported, +}: ImportLegacyDialogProps) { const t = useTranslations("DataExplorer"); const { user } = useAuth(); const { encryptionKey } = useMasterKeyStore(); @@ -55,6 +69,13 @@ export function ImportLegacyDialog({ open, onOpenChange, existing, onImported }: if (!open) return; setCandidates([]); setSkipped(0); + // The unified list hasn't genuinely loaded yet — `existing` cannot be + // trusted for dedupe, so don't fetch legacy candidates against it. + // The effect re-runs once `connectionsLoaded` flips true. + if (!connectionsLoaded) { + setLoading(false); + return; + } if (!user || !encryptionKey) return; let cancelled = false; @@ -89,9 +110,12 @@ export function ImportLegacyDialog({ open, onOpenChange, existing, onImported }: if (cancelled) return; setCandidates(toImport); setSkipped(skippedCount); - } catch (e) { + } catch { + // Never surface the raw error: this flow reads decrypted + // credentials, so an uncontrolled message is a risk, not just + // an i18n gap. if (cancelled) return; - toast.error(e instanceof Error ? e.message : t("toast.legacyImportFailed")); + toast.error(t("toast.legacyImportFailed")); onOpenChange(false); } finally { if (!cancelled) setLoading(false); @@ -104,10 +128,10 @@ export function ImportLegacyDialog({ open, onOpenChange, existing, onImported }: // Re-run only when the dialog opens or its inputs change identity — // not on every `existing` array reference from a parent re-render. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open, user, encryptionKey]); + }, [open, user, encryptionKey, connectionsLoaded]); async function handleConfirm() { - if (!encryptionKey || candidates.length === 0) return; + if (!encryptionKey || candidates.length === 0 || !connectionsLoaded) return; setImporting(true); let imported = 0; let failed = 0; @@ -121,7 +145,10 @@ export function ImportLegacyDialog({ open, onOpenChange, existing, onImported }: } } setImporting(false); - toast.success(t("toast.legacyImportResult", { imported, skipped, failed })); + const message = t("toast.legacyImportResult", { imported, skipped, failed }); + // A total failure is not a success — say so. + if (imported === 0 && failed > 0) toast.error(message); + else toast.success(message); onImported(); onOpenChange(false); } @@ -137,6 +164,10 @@ export function ImportLegacyDialog({ open, onOpenChange, existing, onImported }:

{t("importDialog.loading")}

+ ) : !connectionsLoaded ? ( +

+ {t("importDialog.notReady")} +

) : candidates.length === 0 ? (

{skipped > 0 @@ -177,7 +208,7 @@ export function ImportLegacyDialog({ open, onOpenChange, existing, onImported }: + + )} ({ PanelResizeHandle: () => null, })) -import { redisAdapter, buildRedisUrl, sanitizeRedisError } from "../adapters/redis"; +import { redisAdapter, buildRedisUrl, parseRedisUrl, sanitizeRedisError } from "../adapters/redis"; + +describe("parseRedisUrl", () => { + it("round-trips a URL built by buildRedisUrl", () => { + const url = buildRedisUrl({ + host: "prod", + port: "6380", + username: "u@x", + password: "p:w", + db: "3", + tls: true, + }); + expect(parseRedisUrl(url)).toEqual({ + host: "prod", + port: "6380", + username: "u@x", + password: "p:w", + db: "3", + tls: true, + }); + }); + + it("defaults host and port for a bare URL", () => { + expect(parseRedisUrl("redis://localhost:6379")).toEqual({ + host: "localhost", + port: "6379", + username: "", + password: "", + db: "", + tls: false, + }); + }); + + // A failed parse must fall back to the form's defaults, never throw — + // `validate` only checks the scheme, so a malformed URL is reachable. + it("returns null instead of throwing on unparseable or foreign URLs", () => { + expect(parseRedisUrl("redis://")).toBeNull(); + expect(parseRedisUrl("not a url")).toBeNull(); + expect(parseRedisUrl("http://example.com")).toBeNull(); + expect(parseRedisUrl("")).toBeNull(); + }); +}); describe("buildRedisUrl", () => { const base = { host: "", port: "", username: "", password: "", db: "", tls: false }; diff --git a/apps/desktop-ui/src/components/data-explorer/__tests__/tab-storage.test.ts b/apps/desktop-ui/src/components/data-explorer/__tests__/tab-storage.test.ts new file mode 100644 index 00000000..566b7d92 --- /dev/null +++ b/apps/desktop-ui/src/components/data-explorer/__tests__/tab-storage.test.ts @@ -0,0 +1,42 @@ +import { parseStoredTabs } from "@/lib/data-explorer/tab-storage"; + +const validTab = { + id: "c1:orders", + connectionId: "c1", + sourceId: "mongodb", + title: "orders", + subtitle: "prod › orders", + connectionColor: "#ff0000", + state: { dbName: "shop", collectionName: "orders" }, +}; + +describe("parseStoredTabs", () => { + it("keeps a well-formed tab", () => { + expect(parseStoredTabs(JSON.stringify([validTab]))).toEqual([validTab]); + }); + + it("returns [] for non-array and unparseable values", () => { + expect(parseStoredTabs("{}")).toEqual([]); + expect(parseStoredTabs("null")).toEqual([]); + expect(parseStoredTabs('"nope"')).toEqual([]); + expect(parseStoredTabs("not json")).toEqual([]); + }); + + it("drops entries missing fields the shell or panes read", () => { + const bad = [ + null, + { ...validTab, id: undefined }, + { ...validTab, connectionId: 7 }, + { ...validTab, sourceId: undefined }, + { ...validTab, title: undefined }, + { ...validTab, state: undefined }, + { ...validTab, state: null }, + ]; + expect(parseStoredTabs(JSON.stringify([...bad, validTab]))).toEqual([validTab]); + }); + + it("drops the retired readOnly snapshot instead of carrying it forward", () => { + const [restored] = parseStoredTabs(JSON.stringify([{ ...validTab, readOnly: true }])); + expect(restored).not.toHaveProperty("readOnly"); + }); +}); diff --git a/apps/desktop-ui/src/components/data-explorer/adapters/mongodb.tsx b/apps/desktop-ui/src/components/data-explorer/adapters/mongodb.tsx index 4e6826dd..859d18bf 100644 --- a/apps/desktop-ui/src/components/data-explorer/adapters/mongodb.tsx +++ b/apps/desktop-ui/src/components/data-explorer/adapters/mongodb.tsx @@ -122,7 +122,15 @@ async function testConnection(config: MongoConfig): Promise { * never calls the connection service, never toasts — the connection dialog * owns validate → testConnection → persist and renders `error` inline. */ -function MongoConnectionForm({ initial, saving, error, onSubmit, onCancel }: ConnectionFormProps) { +function MongoConnectionForm({ + initial, + saving, + error, + onTest, + testState, + onSubmit, + onCancel, +}: ConnectionFormProps) { const t = useTranslations("DataExplorer.connectionDialog"); const [name, setName] = useState(initial.name); const [folder, setFolder] = useState(initial.folder ?? ""); @@ -239,8 +247,21 @@ function MongoConnectionForm({ initial, saving, error, onSubmit, onCancel }: Con {error} )} + {testState === "ok" && !error && ( +

+ {t("testOk")} +
+ )}
+ diff --git a/apps/desktop-ui/src/components/data-explorer/adapters/redis.tsx b/apps/desktop-ui/src/components/data-explorer/adapters/redis.tsx index 0f30c6f6..9d3f36ac 100644 --- a/apps/desktop-ui/src/components/data-explorer/adapters/redis.tsx +++ b/apps/desktop-ui/src/components/data-explorer/adapters/redis.tsx @@ -77,6 +77,42 @@ export function buildRedisUrl(opts: { return `${proto}://${auth}${host}:${port}${db}`; } +/** + * Inverse of `buildRedisUrl`: splits a stored URL back into builder fields so + * editing a saved connection and switching to the Builder tab does not rebuild + * `redis://localhost:6379` over the real one. Returns `null` when the URL is + * not a parseable redis(s) URL — the caller then keeps its defaults rather + * than throwing. The password comes back for re-assembly only; the caller + * never renders or logs it. + */ +export function parseRedisUrl(url: string): { + host: string; + port: string; + username: string; + password: string; + db: string; + tls: boolean; +} | null { + try { + const parsed = new URL(url.trim()); + const protocol = parsed.protocol.toLowerCase(); + if (protocol !== "redis:" && protocol !== "rediss:") return null; + // `redis://` parses with an empty host under WHATWG's non-special + // scheme rules; nothing usable to seed from. + if (!parsed.hostname) return null; + return { + host: parsed.hostname, + port: parsed.port || "6379", + username: safeDecode(parsed.username), + password: safeDecode(parsed.password), + db: parsed.pathname.replace(/^\//, ""), + tls: protocol === "rediss:", + }; + } catch { + return null; + } +} + function blankConfig(): RedisConfig { return { redisUrl: "redis://localhost:6379" }; } @@ -162,7 +198,15 @@ async function testConnection(config: RedisConfig): Promise { * Unlike that legacy form, this one never calls the connection service or a * store directly — it only ever reports `{ redisUrl }` up through `onSubmit`. */ -function RedisConnectionForm({ initial, saving, error, onSubmit, onCancel }: ConnectionFormProps) { +function RedisConnectionForm({ + initial, + saving, + error, + onTest, + testState, + onSubmit, + onCancel, +}: ConnectionFormProps) { const t = useTranslations("DataExplorer.connectionDialog"); const tr = useTranslations("DataExplorer.redis"); const [name, setName] = useState(initial.name); @@ -174,22 +218,38 @@ function RedisConnectionForm({ initial, saving, error, onSubmit, onCancel }: Con // Builder fields — only assembled into a URL on submit/preview, never // stored independently, so the URL input stays the single source of truth. - const [host, setHost] = useState("localhost"); - const [port, setPort] = useState("6379"); - const [username, setUsername] = useState(""); + // Seeded from the connection being edited (parsed once): without this, + // opening the Builder tab on a saved connection and saving would submit + // the defaults and silently destroy the stored host and credentials. + const [seed] = useState(() => parseRedisUrl(initial.config.redisUrl)); + const [host, setHost] = useState(seed?.host ?? "localhost"); + const [port, setPort] = useState(seed?.port ?? "6379"); + const [username, setUsername] = useState(seed?.username ?? ""); + // The stored password is deliberately NOT seeded into the input — it is + // never rendered. It is re-attached on submit unless the user types a + // replacement, so a builder save keeps the existing credential. const [password, setPassword] = useState(""); - const [dbIdx, setDbIdx] = useState(""); - const [useTls, setUseTls] = useState(false); + const [passwordEdited, setPasswordEdited] = useState(false); + const [dbIdx, setDbIdx] = useState(seed?.db ?? ""); + const [useTls, setUseTls] = useState(seed?.tls ?? false); + + function currentUrl(): string { + if (mode !== "builder") return redisUrl.trim(); + return buildRedisUrl({ + host, + port, + username, + password: passwordEdited ? password : seed?.password ?? "", + db: dbIdx, + tls: useTls, + }); + } function handleSubmit(e: FormEvent) { e.preventDefault(); - const url = - mode === "builder" - ? buildRedisUrl({ host, port, username, password, db: dbIdx, tls: useTls }) - : redisUrl.trim(); // `readOnly` is passed through untouched, never edited here — see the // note where the switch used to be. - onSubmit({ name, folder, color, readOnly: initial.readOnly, config: { redisUrl: url } }); + onSubmit({ name, folder, color, readOnly: initial.readOnly, config: { redisUrl: currentUrl() } }); } return ( @@ -276,7 +336,14 @@ function RedisConnectionForm({ initial, saving, error, onSubmit, onCancel }: Con setPassword(e.target.value)} + onChange={(e) => { + setPassword(e.target.value); + setPasswordEdited(true); + }} + // Language-neutral marker that a stored + // password is kept unless replaced. The + // password itself is never put in the DOM. + placeholder={seed?.password ? "••••••••" : undefined} disabled={saving} className="h-8 font-mono text-xs" /> @@ -354,8 +421,21 @@ function RedisConnectionForm({ initial, saving, error, onSubmit, onCancel }: Con {error}
)} + {testState === "ok" && !error && ( +
+ {t("testOk")} +
+ )}
+ diff --git a/apps/desktop-ui/src/components/data-explorer/connection-dialog.tsx b/apps/desktop-ui/src/components/data-explorer/connection-dialog.tsx index ad44d32f..ef7328d0 100644 --- a/apps/desktop-ui/src/components/data-explorer/connection-dialog.tsx +++ b/apps/desktop-ui/src/components/data-explorer/connection-dialog.tsx @@ -30,16 +30,47 @@ export function ConnectionDialog({ open, onOpenChange, editing, onSaved }: Conne const [sourceId, setSourceId] = useState(null); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); + const [testState, setTestState] = useState<"idle" | "testing" | "ok">("idle"); useEffect(() => { if (!open) return; setSourceId(editing?.sourceId ?? null); setError(null); setSaving(false); + setTestState("idle"); }, [open, editing]); const adapter = sourceId ? getAdapter(sourceId) : null; + /** + * Explicit, optional user action. Saving never waits on it — a rename or a + * recolour must still persist off-VPN, and a connection to a server that is + * not running yet must be creatable. Both outcomes render inline in the + * dialog so the form (and anything half-typed in it) survives a retry. + */ + async function handleTest(config: unknown) { + if (!adapter) return; + setError(null); + + const invalidKey = adapter.validate(config); + if (invalidKey) { + setError(t(invalidKey)); + return; + } + + setTestState("testing"); + try { + await adapter.testConnection(config); + setTestState("ok"); + } catch (e) { + // Adapters throw pre-sanitised messages, or an empty one when there + // is nothing reportable. Never a raw config. + const message = e instanceof Error ? e.message.trim() : ""; + setError(message || t("connectionDialog.testFailed")); + setTestState("idle"); + } + } + async function handleSubmit(values: ConnectionFormValues) { if (!adapter || !encryptionKey) return; setError(null); @@ -52,7 +83,6 @@ export function ConnectionDialog({ open, onOpenChange, editing, onSaved }: Conne setSaving(true); try { - await adapter.testConnection(values.config); if (editing) { await updateConnection(editing.id, values, encryptionKey); } else { @@ -63,7 +93,7 @@ export function ConnectionDialog({ open, onOpenChange, editing, onSaved }: Conne onOpenChange(false); } catch (e) { const message = e instanceof Error ? e.message.trim() : ""; - setError(message || t("connectionDialog.testFailed")); + setError(message || t("toast.saveFailed")); } finally { setSaving(false); } @@ -112,6 +142,8 @@ export function ConnectionDialog({ open, onOpenChange, editing, onSaved }: Conne }} saving={saving} error={error} + onTest={handleTest} + testState={testState} onSubmit={handleSubmit} onCancel={() => onOpenChange(false)} /> diff --git a/apps/desktop-ui/src/components/data-explorer/types.ts b/apps/desktop-ui/src/components/data-explorer/types.ts index ec44d2a0..d2df24cf 100644 --- a/apps/desktop-ui/src/components/data-explorer/types.ts +++ b/apps/desktop-ui/src/components/data-explorer/types.ts @@ -37,7 +37,12 @@ export interface UnifiedTab { /** Tooltip second line — e.g. "prod-atlas › orders". */ subtitle?: string; connectionColor?: string | null; - readOnly?: boolean; + /** + * No `readOnly` here on purpose. A tab is persisted to localStorage and + * outlives any edit to its connection, so a copy taken at open time goes + * stale the moment the user toggles read-only. Read + * `connection.readOnly` — it is the only live value. + */ /** Adapter-owned. The shell stores and passes it through, never inspects it. */ state: unknown; } @@ -69,6 +74,15 @@ export interface ConnectionFormProps { saving: boolean; /** Persist or test-connection failure, already sanitised. Render inline. */ error: string | null; + /** + * Runs the adapter's `testConnection` against the config currently in the + * form. An explicit, optional user action — never a precondition for + * saving, so a metadata edit still saves with the server unreachable. + * Failures arrive back through `error`; success through `testState`. + */ + onTest: (config: Config) => void; + /** `"testing"` while a test is in flight, `"ok"` after one succeeded. */ + testState: "idle" | "testing" | "ok"; onSubmit: (values: ConnectionFormValues) => void; onCancel: () => void; } diff --git a/apps/desktop-ui/src/components/data-explorer/unified-tab-bar.tsx b/apps/desktop-ui/src/components/data-explorer/unified-tab-bar.tsx index c8300ed9..59e5019b 100644 --- a/apps/desktop-ui/src/components/data-explorer/unified-tab-bar.tsx +++ b/apps/desktop-ui/src/components/data-explorer/unified-tab-bar.tsx @@ -1,5 +1,6 @@ "use client"; +import { useMemo } from "react"; import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; @@ -7,10 +8,13 @@ import { cn } from "@/lib/utils"; import { IconX, IconLock, IconAlertTriangle } from "@tabler/icons-react"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { getAdapter } from "./sources"; -import type { UnifiedTab } from "./types"; +import type { UnifiedConnection, UnifiedTab } from "./types"; interface UnifiedTabBarProps { tabs: UnifiedTab[]; + /** Live connections — the lock glyph reads read-only from these, never + * from a snapshot on the tab, so toggling it updates every open tab. */ + connections: UnifiedConnection[]; activeTabId: string | null; onTabChange: (tabId: string) => void; onTabClose: (tabId: string) => void; @@ -19,12 +23,17 @@ interface UnifiedTabBarProps { export function UnifiedTabBar({ tabs, + connections, activeTabId, onTabChange, onTabClose, onCloseAll, }: UnifiedTabBarProps) { const t = useTranslations("DataExplorer"); + const readOnlyIds = useMemo( + () => new Set(connections.filter((c) => c.readOnly).map((c) => c.id)), + [connections] + ); // Defensive: a malformed entry would crash the whole strip on `tab.id`. const safeTabs = tabs.filter((tab): tab is UnifiedTab => !!tab && typeof tab.id === "string"); @@ -58,7 +67,7 @@ export function UnifiedTabBar({ } > - {tab.readOnly && ( + {readOnlyIds.has(tab.connectionId) && ( )} diff --git a/apps/desktop-ui/src/lib/data-explorer/tab-storage.ts b/apps/desktop-ui/src/lib/data-explorer/tab-storage.ts new file mode 100644 index 00000000..cc9a5973 --- /dev/null +++ b/apps/desktop-ui/src/lib/data-explorer/tab-storage.ts @@ -0,0 +1,49 @@ +import type { UnifiedTab } from "@/components/data-explorer/types"; + +/** + * A restored tab is only as trustworthy as localStorage: an older build, a + * future shape change, or any other writer on the same key can leave a value + * that parses as JSON but is not an array of tabs. `JSON.parse` succeeding is + * not validation — the shell then calls `tabs.find(...)` and the pane reads + * `state.dbName`, so an unchecked value white-screens the tool with no way to + * clear it. + */ +function isStoredTab(value: unknown): value is UnifiedTab { + if (typeof value !== "object" || value === null) return false; + const tab = value as Record; + return ( + typeof tab.id === "string" && + typeof tab.connectionId === "string" && + typeof tab.sourceId === "string" && + typeof tab.title === "string" && + // Every adapter's pane reads its own fields off `state` unguarded. + typeof tab.state === "object" && + tab.state !== null + ); +} + +/** + * Parses persisted tabs, dropping anything malformed. Returns [] for a value + * that is not an array at all — the caller clears the key when nothing + * survives. Each surviving tab is rebuilt field by field, so keys retired + * since it was written (the old `readOnly` snapshot) are dropped instead of + * being carried forward on the next save. + */ +export function parseStoredTabs(raw: string): UnifiedTab[] { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return []; + } + if (!Array.isArray(parsed)) return []; + return parsed.filter(isStoredTab).map((tab) => ({ + id: tab.id, + connectionId: tab.connectionId, + sourceId: tab.sourceId, + title: tab.title, + subtitle: typeof tab.subtitle === "string" ? tab.subtitle : undefined, + connectionColor: typeof tab.connectionColor === "string" ? tab.connectionColor : null, + state: tab.state, + })); +} From e65ae98a2368ea37f278cda3634040fd97a35d5d Mon Sep 17 00:00:00 2001 From: AKHIL Date: Wed, 5 Aug 2026 01:40:39 +0530 Subject: [PATCH 25/26] feat(data-explorer): make the data source picker a dropdown Replaces the two-step grid-of-buttons picker with an always-visible Select at the top of the connection dialog. Creating defaults to the first registered source so the dialog opens ready to type; editing pins the source, since changing what kind of database an existing connection points at was never coherent. Switching source clears any error and test result from the previous one, and the adapter form is keyed by adapter id so no field carries over. Reuses the existing connectionDialog.pickSource label, so no new keys and no change to the 27-locale parity. Co-Authored-By: Claude Opus 5 (1M context) --- .../data-explorer/connection-dialog.tsx | 69 +++++++++++++------ 1 file changed, 49 insertions(+), 20 deletions(-) diff --git a/apps/desktop-ui/src/components/data-explorer/connection-dialog.tsx b/apps/desktop-ui/src/components/data-explorer/connection-dialog.tsx index ef7328d0..51fd0600 100644 --- a/apps/desktop-ui/src/components/data-explorer/connection-dialog.tsx +++ b/apps/desktop-ui/src/components/data-explorer/connection-dialog.tsx @@ -9,7 +9,14 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { cn } from "@/lib/utils"; import { useMasterKeyStore } from "@/store/master-key-store"; import { SOURCES, SOURCE_ORDER, getAdapter } from "./sources"; @@ -34,7 +41,10 @@ export function ConnectionDialog({ open, onOpenChange, editing, onSaved }: Conne useEffect(() => { if (!open) return; - setSourceId(editing?.sourceId ?? null); + // Creating defaults to the first registered source so the dialog opens + // ready to type. Editing pins the connection's own source — changing + // what kind of database an existing connection is was never coherent. + setSourceId(editing?.sourceId ?? SOURCE_ORDER[0] ?? null); setError(null); setSaving(false); setTestState("idle"); @@ -42,6 +52,13 @@ export function ConnectionDialog({ open, onOpenChange, editing, onSaved }: Conne const adapter = sourceId ? getAdapter(sourceId) : null; + /** Switching source discards any error or test result from the previous one. */ + function handleSourceChange(next: SourceId) { + setSourceId(next); + setError(null); + setTestState("idle"); + } + /** * Explicit, optional user action. Saving never waits on it — a rename or a * recolour must still persist off-VPN, and a connection to a server that is @@ -108,31 +125,43 @@ export function ConnectionDialog({ open, onOpenChange, editing, onSaved }: Conne - {!adapter ? ( -
-

- {t("connectionDialog.pickSource")} -

-
+
+ + +
+ + {adapter && ( Date: Wed, 5 Aug 2026 01:44:24 +0530 Subject: [PATCH 26/26] feat(data-explorer): infer Mongo dialect instead of asking for it The source dropdown already says "MongoDB", so the form's second mongodb/documentdb/cosmosdb/ferretdb picker read as a duplicate question. Removed it and derived dbType from the connection string. dbType stays load-bearing: normalizeConnectionString appends retryWrites=false for DocumentDB and Cosmos, which reject the driver's default and would otherwise fail writes silently. Both are identifiable by their managed hostnames, which is what detectDbType matches (already covered by lib/__tests__/nosql-dialects.test.ts). When detection cannot tell, the connection's saved dbType is kept, so an explicit FerretDB choice from an older build survives an edit. The connection-string placeholder still follows the inferred dialect. Co-Authored-By: Claude Opus 5 (1M context) --- .../data-explorer/adapters/mongodb.tsx | 42 ++++++++----------- 1 file changed, 18 insertions(+), 24 deletions(-) diff --git a/apps/desktop-ui/src/components/data-explorer/adapters/mongodb.tsx b/apps/desktop-ui/src/components/data-explorer/adapters/mongodb.tsx index 859d18bf..e2fac74c 100644 --- a/apps/desktop-ui/src/components/data-explorer/adapters/mongodb.tsx +++ b/apps/desktop-ui/src/components/data-explorer/adapters/mongodb.tsx @@ -50,7 +50,7 @@ import { } from "@/lib/nosql-error-sanitizer"; import { DB_DIALECTS, - DB_TYPE_ORDER, + detectDbType, normalizeConnectionString, type DbType, } from "@/lib/nosql-dialects"; @@ -137,7 +137,23 @@ function MongoConnectionForm({ const [color, setColor] = useState(initial.color ?? null); const [readOnly, setReadOnly] = useState(initial.readOnly ?? false); const [connectionString, setConnectionString] = useState(initial.config.connectionString); - const [dbType, setDbType] = useState(initial.config.dbType); + + /** + * Inferred from the connection string, not picked by the user — the source + * dropdown already said "MongoDB" and asking again read as a duplicate. + * + * `dbType` is still load-bearing: `normalizeConnectionString` appends + * `retryWrites=false` for DocumentDB and Cosmos, which reject the driver's + * default and would otherwise fail writes silently. Both are identifiable + * by their managed hostnames, which is exactly what `detectDbType` matches. + * It falls back to "mongodb" when it cannot tell, so keep whatever the + * connection was saved with in that case — an explicit FerretDB choice from + * an older build then survives an edit. + */ + const dbType = useMemo(() => { + const detected = detectDbType(connectionString); + return detected === "mongodb" ? initial.config.dbType : detected; + }, [connectionString, initial.config.dbType]); function handleSubmit(e: React.FormEvent) { e.preventDefault(); @@ -168,28 +184,6 @@ function MongoConnectionForm({ />
-
- -
- {DB_TYPE_ORDER.map((type) => ( - - ))} -
-
-