From dc70587081757e23bd7e7f2fd642cddbd4d650f3 Mon Sep 17 00:00:00 2001 From: aimeritething Date: Wed, 3 Jun 2026 10:42:34 +0800 Subject: [PATCH 1/6] sync sidebar focus with active tabs --- CONTEXT.md | 24 +++ dataflow/src/components/sidebar/Sidebar.tsx | 17 +- .../sidebar/SidebarTree/SidebarTree.Node.tsx | 9 +- .../SidebarTree/SidebarTreeProvider.tsx | 51 +++++ .../components/sidebar/sidebar-selection.ts | 189 ++++++++++++++++++ dataflow/src/stores/useTabStore.ts | 1 + dataflow/src/test/sidebar-selection.test.ts | 188 +++++++++++++++++ 7 files changed, 475 insertions(+), 4 deletions(-) create mode 100644 dataflow/src/components/sidebar/sidebar-selection.ts create mode 100644 dataflow/src/test/sidebar-selection.test.ts diff --git a/CONTEXT.md b/CONTEXT.md index e774e8292..4ddb07e9b 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -44,6 +44,18 @@ _Avoid_: document table mode The editor for changing an entire **MongoDB Document** as JSON. _Avoid_: document table mode, field-level editor +**Workspace Tab**: +A database exploration surface tied to a query or a storage unit. +_Avoid_: browser tab, panel + +**Sidebar Focus**: +The sidebar tree item that represents the active **Workspace Tab**'s closest database context. +_Avoid_: hover state, keyboard focus + +**Sidebar Reveal**: +The behavior that makes the **Sidebar Focus** visible in the sidebar tree. +_Avoid_: expand all, jump to item + ## Relationships - A **MongoDB Collection** contains zero or more **MongoDB Documents**. @@ -58,11 +70,21 @@ _Avoid_: document table mode, field-level editor - A **Field JSON Editor** accepts any valid JSON value, even when that changes an object or array field into a scalar or `null`. - A **MongoDB Document** is edited through a **Document JSON Editor**. - A **Complex Document Field** is not edited through a separate field-level interaction inside the **Document JSON Editor**. +- A **Workspace Tab** has zero or one **Sidebar Focus**. +- A storage-unit **Workspace Tab** focuses its table, view, collection, or Redis key. +- A query **Workspace Tab** focuses the schema, database, or connection, whichever is most specific. +- A **Sidebar Reveal** expands collapsed ancestors of the **Sidebar Focus** and scrolls the focus into view. ## Example Dialogue > **Dev:** "Should MongoDB open in the table by default?" > **Domain expert:** "Yes. Open MongoDB collections in the **Collection Table View** by default because users expect a grid for browsing. Keep the **JSON View** available as a switchable document-focused view." +> +> **Dev:** "When users switch between **Workspace Tabs**, should the sidebar keep the last clicked tree item?" +> **Domain expert:** "No. The sidebar should show the **Sidebar Focus** for the active **Workspace Tab**." +> +> **Dev:** "If that focus is hidden under a collapsed folder or outside the visible sidebar area, should we leave the tree as-is?" +> **Domain expert:** "No. Use **Sidebar Reveal** so the focused item is visible without expanding unrelated branches." ## Flagged Ambiguities @@ -70,6 +92,8 @@ _Avoid_: document table mode, field-level editor - "table view" in MongoDB means **Collection Table View**, not a relational database table. - The document editor should be a **Document JSON Editor**, not a table view, field list, or field-level editor. +- "focus" in the sidebar means **Sidebar Focus**, not hover state or keyboard focus. +- "auto expand" in the sidebar means **Sidebar Reveal**, not expanding every folder in the tree. - The **Collection Table View** is the default MongoDB collection view. - The **Collection Table View** should build its first column set from a limited default sample, not by scanning the full collection. - The **Collection Table View** supports sorting and filtering on top-level document fields. diff --git a/dataflow/src/components/sidebar/Sidebar.tsx b/dataflow/src/components/sidebar/Sidebar.tsx index 52e0760a6..163a331cb 100644 --- a/dataflow/src/components/sidebar/Sidebar.tsx +++ b/dataflow/src/components/sidebar/Sidebar.tsx @@ -1,4 +1,4 @@ -import React, { useState, useCallback, useReducer } from "react"; +import React, { useState, useCallback, useEffect, useReducer } from "react"; import { useConnectionStore } from "@/stores/useConnectionStore"; import { useTabStore } from "@/stores/useTabStore"; @@ -22,6 +22,7 @@ import { } from "./contextMenuItems"; import { SidebarModals } from "./SidebarModals"; import { useI18n } from "@/i18n/useI18n"; +import { getSidebarSelectionForTab } from "./sidebar-selection"; // ── Modal reducer (inlined from former useSidebarModals) ──────────── @@ -58,12 +59,12 @@ function modalReducer(_state: ModalState | null, action: Action): ModalState | n function SidebarInner() { const { connections, selectedItem, selectItem, systemSchemas, showSystemObjectsFor, toggleSystemObjects, triggerCollectionRefresh } = useConnectionStore(); - const { openTab } = useTabStore(); + const { tabs, activeTabId, openTab } = useTabStore(); const { t } = useI18n(); const { expandedItems, treeData, isLoading, - toggleItem, fetchNodeChildren, refreshNode, + toggleItem, fetchNodeChildren, refreshNode, revealNode, } = useSidebarTree(); // Modal state (inlined from former useSidebarModals) @@ -92,6 +93,15 @@ function SidebarInner() { node: TreeNodeData; } | null>(null); + useEffect(() => { + const activeTab = tabs.find((tab) => tab.id === activeTabId) ?? null; + const selection = getSidebarSelectionForTab(activeTab, connections); + selectItem(selection); + if (selection) { + revealNode(selection); + } + }, [activeTabId, connections, revealNode, selectItem, tabs]); + const handleItemClick = useCallback( async (node: TreeNodeData) => { selectItem(node); @@ -121,6 +131,7 @@ function SidebarInner() { databaseName: node.metadata.database, schemaName: node.metadata.schema, tableName: node.name, + storageUnitType: node.type, }); } else if (node.type === "collection") { const collectionTitle = node.metadata.database diff --git a/dataflow/src/components/sidebar/SidebarTree/SidebarTree.Node.tsx b/dataflow/src/components/sidebar/SidebarTree/SidebarTree.Node.tsx index 3459a18c9..5394886f7 100644 --- a/dataflow/src/components/sidebar/SidebarTree/SidebarTree.Node.tsx +++ b/dataflow/src/components/sidebar/SidebarTree/SidebarTree.Node.tsx @@ -1,4 +1,4 @@ -import React, { createContext, use } from "react"; +import React, { createContext, use, useEffect, useRef } from "react"; import { ChevronRight, ChevronDown, Loader2, Database, ListTree, Table, Files, Eye, Folder, @@ -55,6 +55,7 @@ interface TreeNodeProps { } export function TreeNode({ node, depth }: TreeNodeProps) { + const nodeRef = useRef(null); const { expandedItems, isLoading: loadingItems, treeData } = useSidebarTree(); const { selectedItemId, @@ -76,9 +77,15 @@ export function TreeNode({ node, depth }: TreeNodeProps) { : NODE_ICONS[node.type]; const brandIcon = isRoot ? DB_ICONS[connectionDbType] : null; + useEffect(() => { + if (!isSelected) return; + nodeRef.current?.scrollIntoView({ block: "nearest", inline: "nearest" }); + }, [isSelected]); + return (
Promise; refreshNode: (node: TreeNodeData) => Promise; collapseNode: (nodeId: string) => void; + revealNode: (node: TreeNodeData) => Promise; } const SidebarTreeContext = createContext(null); @@ -36,6 +38,11 @@ export function SidebarTreeProvider({ children }: { children: React.ReactNode }) const [isLoading, setIsLoading] = useState>({}); const [isRestoring, setIsRestoring] = useState(true); const hasRestored = useRef(false); + const treeDataRef = useRef(treeData); + + useEffect(() => { + treeDataRef.current = treeData; + }, [treeData]); // Persist expanded items to localStorage useEffect(() => { @@ -262,6 +269,49 @@ export function SidebarTreeProvider({ children }: { children: React.ReactNode }) }); }, []); + /** Expand the collapsed ancestors needed to render a focused node. */ + const revealNode = useCallback( + async (node: TreeNodeData) => { + const ancestors = getSidebarRevealAncestors(node, connections, { + redisKeysFolder: t("sidebar.redis.keysFolder"), + tablesFolder: t("sidebar.tree.tables"), + viewsFolder: t("sidebar.tree.views"), + }); + const fetchedTreeData: Record = {}; + const knownTreeData = { ...treeDataRef.current }; + + for (const ancestor of ancestors) { + const shouldFetch = + !knownTreeData[ancestor.id] || + ancestor.type === "database" || + ancestor.type === "redis_keys_folder"; + if (!shouldFetch) continue; + + setIsLoading((prev) => ({ ...prev, [ancestor.id]: true })); + try { + const children = await buildChildren(ancestor); + fetchedTreeData[ancestor.id] = children; + knownTreeData[ancestor.id] = children; + } catch (error) { + console.error("Failed to reveal node:", node.id, error); + throw error; + } finally { + setIsLoading((prev) => ({ ...prev, [ancestor.id]: false })); + } + } + + setTreeData((prev) => ({ ...prev, ...fetchedTreeData })); + setExpandedItems((prev) => { + const next = new Set(prev); + for (const ancestor of ancestors) { + next.add(ancestor.id); + } + return next; + }); + }, + [buildChildren, connections, t], + ); + // Restore expanded state from localStorage on mount (runs once) // Waits for systemSchemas to load first so filtering is applied correctly useEffect(() => { @@ -334,6 +384,7 @@ export function SidebarTreeProvider({ children }: { children: React.ReactNode }) fetchNodeChildren, refreshNode, collapseNode, + revealNode, }; return ( diff --git a/dataflow/src/components/sidebar/sidebar-selection.ts b/dataflow/src/components/sidebar/sidebar-selection.ts new file mode 100644 index 000000000..31e4791fc --- /dev/null +++ b/dataflow/src/components/sidebar/sidebar-selection.ts @@ -0,0 +1,189 @@ +import type { Connection } from '@/stores/useConnectionStore' +import type { Tab } from '@/stores/useTabStore' +import type { TreeNodeData } from './SidebarTree/types' + +interface SidebarTreeLabels { + redisKeysFolder: string + tablesFolder: string + viewsFolder: string +} + +function connectionNode(connectionId: string, connections: Connection[]): TreeNodeData | null { + const connection = connections.find((item) => item.id === connectionId) + if (!connection) return null + + return { + type: 'connection', + id: connection.id, + name: connection.name, + connectionId: connection.id, + metadata: {}, + } +} + +function databaseNode(connectionId: string, databaseName: string | undefined): TreeNodeData | null { + if (!databaseName) return null + + return { + type: 'database', + id: `${connectionId}-${databaseName}`, + name: databaseName, + parentId: connectionId, + connectionId, + metadata: { database: databaseName }, + } +} + +function schemaNode(connectionId: string, databaseName: string | undefined, schemaName: string | undefined): TreeNodeData | null { + if (!databaseName || !schemaName) return null + + const databaseId = `${connectionId}-${databaseName}` + return { + type: 'schema', + id: `${databaseId}-${schemaName}`, + name: schemaName, + parentId: databaseId, + connectionId, + metadata: { database: databaseName, schema: schemaName }, + } +} + +function storageUnitNode(tab: Tab): TreeNodeData | null { + if (!tab.databaseName || !tab.tableName) return null + + const databaseId = `${tab.connectionId}-${tab.databaseName}` + const storageUnitType = tab.storageUnitType ?? 'table' + const parentId = tab.schemaName + ? `${databaseId}-${tab.schemaName}-${storageUnitType === 'view' ? 'views' : 'tables'}` + : databaseId + + return { + type: storageUnitType, + id: `${parentId}-${tab.tableName}`, + name: tab.tableName, + parentId, + connectionId: tab.connectionId, + metadata: { + database: tab.databaseName, + schema: tab.schemaName, + table: tab.tableName, + }, + } +} + +function collectionNode(tab: Tab): TreeNodeData | null { + if (!tab.databaseName || !tab.collectionName) return null + + const databaseId = `${tab.connectionId}-${tab.databaseName}` + return { + type: 'collection', + id: `${databaseId}-${tab.collectionName}`, + name: tab.collectionName, + parentId: databaseId, + connectionId: tab.connectionId, + metadata: { database: tab.databaseName, table: tab.collectionName }, + } +} + +function redisKeyNode(tab: Tab): TreeNodeData | null { + if (!tab.databaseName || !tab.tableName) return null + + const databaseId = `${tab.connectionId}-${tab.databaseName}` + const keysFolderId = `${databaseId}-keys` + return { + type: 'redis_key', + id: `${keysFolderId}-${tab.tableName}`, + name: tab.tableName, + parentId: keysFolderId, + connectionId: tab.connectionId, + metadata: { database: tab.databaseName }, + } +} + +/** Derives the sidebar focus that should represent the active workspace tab. */ +export function getSidebarSelectionForTab(tab: Tab | null, connections: Connection[]): TreeNodeData | null { + if (!tab) return null + + if (tab.type === 'query') { + return ( + schemaNode(tab.connectionId, tab.databaseName, tab.schemaName) ?? + databaseNode(tab.connectionId, tab.databaseName) ?? + connectionNode(tab.connectionId, connections) + ) + } + + if (tab.type === 'table') { + return storageUnitNode(tab) + } + + if (tab.type === 'collection') { + return collectionNode(tab) + } + + if (tab.type === 'redis_key_detail') { + return redisKeyNode(tab) + } + + return null +} + +/** Returns the collapsed ancestors that must be expanded to show a sidebar focus. */ +export function getSidebarRevealAncestors( + selection: TreeNodeData | null, + connections: Connection[], + labels: SidebarTreeLabels, +): TreeNodeData[] { + if (!selection) return [] + + const connection = connectionNode(selection.connectionId, connections) + if (!connection) return [] + + const database = databaseNode(selection.connectionId, selection.metadata.database) + const schema = schemaNode(selection.connectionId, selection.metadata.database, selection.metadata.schema) + + if (selection.type === 'connection') return [] + if (selection.type === 'database') return [connection] + if (selection.type === 'schema') return [connection, database].filter((node): node is TreeNodeData => Boolean(node)) + + if (selection.type === 'redis_key') { + if (!database) return [connection] + return [ + connection, + database, + { + type: 'redis_keys_folder', + id: `${database.id}-keys`, + name: labels.redisKeysFolder, + parentId: database.id, + connectionId: selection.connectionId, + metadata: { database: selection.metadata.database }, + }, + ] + } + + if ((selection.type === 'table' || selection.type === 'view') && schema) { + const folderType = selection.type === 'view' ? 'view_folder' : 'table_folder' + return [ + connection, + database, + schema, + { + type: folderType, + id: `${schema.id}-${selection.type === 'view' ? 'views' : 'tables'}`, + name: selection.type === 'view' ? labels.viewsFolder : labels.tablesFolder, + parentId: schema.id, + connectionId: selection.connectionId, + metadata: { + database: selection.metadata.database, + schema: selection.metadata.schema, + }, + }, + ].filter((node): node is TreeNodeData => Boolean(node)) + } + + if (selection.type === 'table' || selection.type === 'view' || selection.type === 'collection') { + return [connection, database].filter((node): node is TreeNodeData => Boolean(node)) + } + + return [] +} diff --git a/dataflow/src/stores/useTabStore.ts b/dataflow/src/stores/useTabStore.ts index 708b62e2b..3e14325e6 100644 --- a/dataflow/src/stores/useTabStore.ts +++ b/dataflow/src/stores/useTabStore.ts @@ -11,6 +11,7 @@ export interface Tab { schemaName?: string; sqlContent?: string; tableName?: string; + storageUnitType?: 'table' | 'view'; collectionName?: string; isDirty?: boolean; } diff --git a/dataflow/src/test/sidebar-selection.test.ts b/dataflow/src/test/sidebar-selection.test.ts new file mode 100644 index 000000000..e1ee34a65 --- /dev/null +++ b/dataflow/src/test/sidebar-selection.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, it } from 'vitest' +import { getSidebarRevealAncestors, getSidebarSelectionForTab } from '@/components/sidebar/sidebar-selection' +import type { Connection } from '@/stores/useConnectionStore' +import type { Tab } from '@/stores/useTabStore' + +const connections: Connection[] = [ + { + id: 'sealos', + name: 'Postgres @ localhost', + type: 'POSTGRES', + host: 'localhost', + port: '5432', + user: '', + password: '', + database: 'app', + createdAt: '2026-06-03T00:00:00.000Z', + }, +] + +function tab(overrides: Partial): Tab { + return { + id: 'tab-1', + type: 'query', + title: 'Query', + connectionId: 'sealos', + ...overrides, + } +} + +describe('getSidebarSelectionForTab', () => { + it('focuses the active relational table tab', () => { + const selection = getSidebarSelectionForTab( + tab({ + type: 'table', + databaseName: 'app', + schemaName: 'public', + tableName: 'orders', + storageUnitType: 'table', + }), + connections, + ) + + expect(selection).toMatchObject({ + type: 'table', + id: 'sealos-app-public-tables-orders', + parentId: 'sealos-app-public-tables', + metadata: { database: 'app', schema: 'public', table: 'orders' }, + }) + }) + + it('focuses the active relational view tab', () => { + const selection = getSidebarSelectionForTab( + tab({ + type: 'table', + databaseName: 'app', + schemaName: 'public', + tableName: 'daily_orders', + storageUnitType: 'view', + }), + connections, + ) + + expect(selection).toMatchObject({ + type: 'view', + id: 'sealos-app-public-views-daily_orders', + parentId: 'sealos-app-public-views', + }) + }) + + it('focuses the active collection tab', () => { + const selection = getSidebarSelectionForTab( + tab({ + type: 'collection', + databaseName: 'app', + collectionName: 'events', + }), + connections, + ) + + expect(selection).toMatchObject({ + type: 'collection', + id: 'sealos-app-events', + parentId: 'sealos-app', + metadata: { database: 'app', table: 'events' }, + }) + }) + + it('focuses the nearest query context', () => { + expect( + getSidebarSelectionForTab(tab({ type: 'query', databaseName: 'app', schemaName: 'public' }), connections), + ).toMatchObject({ type: 'schema', id: 'sealos-app-public' }) + + expect(getSidebarSelectionForTab(tab({ type: 'query', databaseName: 'app' }), connections)).toMatchObject({ + type: 'database', + id: 'sealos-app', + }) + + expect(getSidebarSelectionForTab(tab({ type: 'query' }), connections)).toMatchObject({ + type: 'connection', + id: 'sealos', + }) + }) + + it('focuses the active Redis key tab', () => { + const selection = getSidebarSelectionForTab( + tab({ + type: 'redis_key_detail', + databaseName: '0', + tableName: 'session:1', + }), + connections, + ) + + expect(selection).toMatchObject({ + type: 'redis_key', + id: 'sealos-0-keys-session:1', + parentId: 'sealos-0-keys', + metadata: { database: '0' }, + }) + }) +}) + +describe('getSidebarRevealAncestors', () => { + it('expands the path to a relational table inside a schema folder', () => { + const selection = getSidebarSelectionForTab( + tab({ + type: 'table', + databaseName: 'app', + schemaName: 'public', + tableName: 'orders', + storageUnitType: 'table', + }), + connections, + ) + + expect(getSidebarRevealAncestors(selection, connections, { + redisKeysFolder: 'Keys', + tablesFolder: 'Tables', + viewsFolder: 'Views', + })).toMatchObject([ + { type: 'connection', id: 'sealos' }, + { type: 'database', id: 'sealos-app' }, + { type: 'schema', id: 'sealos-app-public' }, + { type: 'table_folder', id: 'sealos-app-public-tables' }, + ]) + }) + + it('expands the path to a Redis key folder', () => { + const selection = getSidebarSelectionForTab( + tab({ + type: 'redis_key_detail', + databaseName: '0', + tableName: 'session:1', + }), + connections, + ) + + expect(getSidebarRevealAncestors(selection, connections, { + redisKeysFolder: 'Keys', + tablesFolder: 'Tables', + viewsFolder: 'Views', + })).toMatchObject([ + { type: 'connection', id: 'sealos' }, + { type: 'database', id: 'sealos-0' }, + { type: 'redis_keys_folder', id: 'sealos-0-keys', name: 'Keys' }, + ]) + }) + + it('only expands ancestors of the focused query context', () => { + const selection = getSidebarSelectionForTab( + tab({ + type: 'query', + databaseName: 'app', + schemaName: 'public', + }), + connections, + ) + + expect(getSidebarRevealAncestors(selection, connections, { + redisKeysFolder: 'Keys', + tablesFolder: 'Tables', + viewsFolder: 'Views', + })).toMatchObject([ + { type: 'connection', id: 'sealos' }, + { type: 'database', id: 'sealos-app' }, + ]) + }) +}) From 028aecf79676fa5ad40dc6b37a277d95c888b693 Mon Sep 17 00:00:00 2001 From: aimeritething Date: Wed, 3 Jun 2026 11:05:07 +0800 Subject: [PATCH 2/6] Tighten sidebar tab focus sync --- dataflow/src/components/sidebar/Sidebar.tsx | 39 +++++++++++--- dataflow/src/stores/useTabStore.ts | 15 ++++-- dataflow/src/test/useTabStore.test.ts | 60 +++++++++++++++++++++ 3 files changed, 103 insertions(+), 11 deletions(-) create mode 100644 dataflow/src/test/useTabStore.test.ts diff --git a/dataflow/src/components/sidebar/Sidebar.tsx b/dataflow/src/components/sidebar/Sidebar.tsx index 163a331cb..25459eb89 100644 --- a/dataflow/src/components/sidebar/Sidebar.tsx +++ b/dataflow/src/components/sidebar/Sidebar.tsx @@ -1,7 +1,7 @@ -import React, { useState, useCallback, useEffect, useReducer } from "react"; +import React, { useState, useCallback, useEffect, useMemo, useReducer } from "react"; import { useConnectionStore } from "@/stores/useConnectionStore"; -import { useTabStore } from "@/stores/useTabStore"; +import { useTabStore, type Tab } from "@/stores/useTabStore"; import { ContextMenu } from "../ui/ContextMenu"; import type { Alert } from "@/components/ui/types"; @@ -24,6 +24,21 @@ import { SidebarModals } from "./SidebarModals"; import { useI18n } from "@/i18n/useI18n"; import { getSidebarSelectionForTab } from "./sidebar-selection"; +function getSidebarFocusKey(tab: Tab | null): string { + if (!tab) return "no-active-tab"; + + return JSON.stringify([ + tab.id, + tab.type, + tab.connectionId, + tab.databaseName ?? null, + tab.schemaName ?? null, + tab.tableName ?? null, + tab.storageUnitType ?? null, + tab.collectionName ?? null, + ]); +} + // ── Modal reducer (inlined from former useSidebarModals) ──────────── /** All possible modal types and their parameter shapes */ @@ -93,14 +108,22 @@ function SidebarInner() { node: TreeNodeData; } | null>(null); + const activeTab = tabs.find((tab) => tab.id === activeTabId) ?? null; + const sidebarFocusKey = getSidebarFocusKey(activeTab); + const sidebarSelection = useMemo( + () => getSidebarSelectionForTab(activeTab, connections), + // Ignore title/sqlContent/isDirty changes; they do not affect sidebar focus. + [connections, sidebarFocusKey], + ); + useEffect(() => { - const activeTab = tabs.find((tab) => tab.id === activeTabId) ?? null; - const selection = getSidebarSelectionForTab(activeTab, connections); - selectItem(selection); - if (selection) { - revealNode(selection); + selectItem(sidebarSelection); + if (sidebarSelection) { + void revealNode(sidebarSelection).catch((error) => { + console.error("Failed to reveal sidebar selection:", sidebarSelection.id, error); + }); } - }, [activeTabId, connections, revealNode, selectItem, tabs]); + }, [revealNode, selectItem, sidebarSelection]); const handleItemClick = useCallback( async (node: TreeNodeData) => { diff --git a/dataflow/src/stores/useTabStore.ts b/dataflow/src/stores/useTabStore.ts index 3e14325e6..f97449e70 100644 --- a/dataflow/src/stores/useTabStore.ts +++ b/dataflow/src/stores/useTabStore.ts @@ -23,7 +23,13 @@ interface TabState { closeTab: (tabId: string) => void; setActiveTab: (tabId: string) => void; updateTab: (tabId: string, updates: Partial) => void; - findExistingTab: (type: TabType, connectionId: string, identifier: string, databaseName?: string) => Tab | undefined; + findExistingTab: ( + type: TabType, + connectionId: string, + identifier: string, + databaseName?: string, + storageUnitType?: Tab['storageUnitType'], + ) => Tab | undefined; closeOtherTabs: (tabId: string) => void; closeAllTabs: () => void; } @@ -36,11 +42,13 @@ export const useTabStore = create((set, get) => ({ tabs: [], activeTabId: null, - findExistingTab: (type, connectionId, identifier, databaseName) => { + findExistingTab: (type, connectionId, identifier, databaseName, storageUnitType) => { return get().tabs.find((tab) => { if (tab.type !== type || tab.connectionId !== connectionId) return false; if (databaseName && tab.databaseName !== databaseName) return false; - if (type === 'table') return tab.tableName === identifier; + if (type === 'table') { + return tab.tableName === identifier && (tab.storageUnitType ?? 'table') === (storageUnitType ?? 'table'); + } if (type === 'collection') return tab.collectionName === identifier; if (type === 'redis_key_detail') return tab.tableName === identifier; return false; @@ -56,6 +64,7 @@ export const useTabStore = create((set, get) => ({ tabData.connectionId, tabData.tableName || tabData.collectionName || '', tabData.databaseName, + tabData.storageUnitType, ) : undefined; diff --git a/dataflow/src/test/useTabStore.test.ts b/dataflow/src/test/useTabStore.test.ts new file mode 100644 index 000000000..ecbbc9a65 --- /dev/null +++ b/dataflow/src/test/useTabStore.test.ts @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { useTabStore } from '@/stores/useTabStore' + +const initialState = useTabStore.getState() + +describe('useTabStore', () => { + beforeEach(() => { + useTabStore.setState(initialState) + }) + + it('keeps table and view workspace tabs distinct when they share a name', () => { + const tableTabId = useTabStore.getState().openTab({ + type: 'table', + title: 'orders', + connectionId: 'sealos', + databaseName: 'app', + schemaName: 'public', + tableName: 'orders', + storageUnitType: 'table', + }) + + const viewTabId = useTabStore.getState().openTab({ + type: 'table', + title: 'orders', + connectionId: 'sealos', + databaseName: 'app', + schemaName: 'public', + tableName: 'orders', + storageUnitType: 'view', + }) + + expect(viewTabId).not.toBe(tableTabId) + expect(useTabStore.getState().tabs).toHaveLength(2) + }) + + it('reuses matching view workspace tabs', () => { + const firstTabId = useTabStore.getState().openTab({ + type: 'table', + title: 'daily_orders', + connectionId: 'sealos', + databaseName: 'app', + schemaName: 'public', + tableName: 'daily_orders', + storageUnitType: 'view', + }) + + const secondTabId = useTabStore.getState().openTab({ + type: 'table', + title: 'daily_orders', + connectionId: 'sealos', + databaseName: 'app', + schemaName: 'public', + tableName: 'daily_orders', + storageUnitType: 'view', + }) + + expect(secondTabId).toBe(firstTabId) + expect(useTabStore.getState().tabs).toHaveLength(1) + }) +}) From a54a3910f9b64528b12760798e965ab066210464 Mon Sep 17 00:00:00 2001 From: aimeritething Date: Wed, 3 Jun 2026 11:05:53 +0800 Subject: [PATCH 3/6] update skills --- .gitignore | 4 +++- skills-lock.json | 6 ++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index b5594a220..67f3ed790 100644 --- a/.gitignore +++ b/.gitignore @@ -62,4 +62,6 @@ dataflow/.env .agents/ .worktrees/ -.claude/ \ No newline at end of file +.claude/ + +.github/skills/ \ No newline at end of file diff --git a/skills-lock.json b/skills-lock.json index c6ee99e6e..588bec7c1 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -21,6 +21,12 @@ "sourceType": "github", "computedHash": "1f3e1f005fc40fda85d250ae906c8ee4f7e8e833e23adfeca6542c19609fd7cd" }, + "review-new-work": { + "source": "aimeritething/skills", + "sourceType": "github", + "skillPath": "skills/review-new-work/SKILL.md", + "computedHash": "bbec7c9cf57c988356cfca5c814684be93507e3d2041696a97a7f63334055e23" + }, "sealos-app-builder": { "source": "labring/seakills", "sourceType": "github", From 67c4495d6999365a55aa6a80691f2394178dd15a Mon Sep 17 00:00:00 2001 From: aimeritething Date: Wed, 3 Jun 2026 11:55:59 +0800 Subject: [PATCH 4/6] update buttons --- .impeccable/live/config.json | 6 + PRODUCT.md | 33 +++++ .../CollectionView/CollectionView.Toolbar.tsx | 118 ++++++++++++++---- dataflow/src/i18n/locales/en/mongodb.ts | 1 + dataflow/src/i18n/locales/zh/mongodb.ts | 1 + 5 files changed, 135 insertions(+), 24 deletions(-) create mode 100644 .impeccable/live/config.json create mode 100644 PRODUCT.md diff --git a/.impeccable/live/config.json b/.impeccable/live/config.json new file mode 100644 index 000000000..5c9153468 --- /dev/null +++ b/.impeccable/live/config.json @@ -0,0 +1,6 @@ +{ + "files": ["dataflow/index.html"], + "insertBefore": "", + "commentSyntax": "html", + "cspChecked": true +} diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 000000000..84ef5754e --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,33 @@ +# Product + +## Register + +product + +## Users + +DataFlow is used by developers, operators, and data-facing teammates who need to inspect and maintain databases from a single workspace. They are usually browsing collections, tables, keys, and query results while keeping enough context to avoid accidental data changes. + +## Product Purpose + +DataFlow provides a Sealos-native database workspace for browsing records, running SQL, MongoDB, and Redis commands, and turning query results into charts and dashboards. Success means users can move from database structure to data editing or analysis with clear state, predictable controls, and low friction. + +## Brand Personality + +Lightweight, capable, calm. The interface should feel less heavy than a traditional admin console while still earning trust for database operations. + +## Anti-references + +Avoid dense enterprise-console clutter, decorative dashboards that slow down the task, and playful visual treatments that make resource mutation feel casual. Avoid calling MongoDB collections tables except when referring specifically to the Collection Table View. + +## Design Principles + +- Keep the workspace light enough for daily use. +- Make database state and pending mutations visible before action. +- Use familiar product UI patterns for high-frequency controls. +- Preserve MongoDB's document model while offering grid-based browsing. +- Let analysis tools sit close to the data without competing with core browsing. + +## Accessibility & Inclusion + +Target WCAG AA for contrast, focus visibility, keyboard access, and readable control labels. Respect reduced-motion preferences and avoid using color alone to communicate state. diff --git a/dataflow/src/components/database/mongodb/CollectionView/CollectionView.Toolbar.tsx b/dataflow/src/components/database/mongodb/CollectionView/CollectionView.Toolbar.tsx index 0a59f70d1..f54ad3f43 100644 --- a/dataflow/src/components/database/mongodb/CollectionView/CollectionView.Toolbar.tsx +++ b/dataflow/src/components/database/mongodb/CollectionView/CollectionView.Toolbar.tsx @@ -2,6 +2,7 @@ import { useState } from 'react' import { Download, Plus, Minus, Undo2, Eye, SendHorizontal, RefreshCw, TerminalSquare, BarChart3, Table2, FileJson } from 'lucide-react' import { Separator } from '@/components/ui/separator' import { useCollectionView } from './CollectionViewProvider' +import type { MongoCollectionViewMode } from './types' import { DataView } from '@/components/database/shared/DataView' import { Button } from '@/components/ui/Button' import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' @@ -23,8 +24,6 @@ export function CollectionViewToolbar({ connectionId, databaseName, collectionNa const { state, actions } = useCollectionView() const openTab = useTabStore((s) => s.openTab) const [isChartModalOpen, setIsChartModalOpen] = useState(false) - const nextViewMode = state.viewMode === 'table' ? 'json' : 'table' - const ViewSwitchIcon = nextViewMode === 'table' ? Table2 : FileJson const handleOpenQuery = () => { openTab({ @@ -71,28 +70,6 @@ export function CollectionViewToolbar({ connectionId, databaseName, collectionNa - - - - - - {nextViewMode === 'table' ? t('mongodb.view.switchToTable') : t('mongodb.view.switchToJson')} - - - - -
) } + +interface CollectionViewModeSwitchProps { + currentMode: MongoCollectionViewMode + onSelect: (mode: MongoCollectionViewMode) => void +} + +/** Two-option MongoDB collection view-mode switch. */ +function CollectionViewModeSwitch({ currentMode, onSelect }: CollectionViewModeSwitchProps) { + const { t } = useI18n() + + return ( +
+
+ ) +} + +interface CollectionViewModeButtonProps { + mode: MongoCollectionViewMode + currentMode: MongoCollectionViewMode + ariaLabel: string + tooltip: string + onSelect: (mode: MongoCollectionViewMode) => void +} + +/** Single option in the MongoDB collection view-mode control. */ +function CollectionViewModeButton({ mode, currentMode, ariaLabel, tooltip, onSelect }: CollectionViewModeButtonProps) { + const active = currentMode === mode + const Icon = mode === 'table' ? Table2 : FileJson + + return ( + + + + + {tooltip} + + ) +} diff --git a/dataflow/src/i18n/locales/en/mongodb.ts b/dataflow/src/i18n/locales/en/mongodb.ts index 6b3febb65..a67183020 100644 --- a/dataflow/src/i18n/locales/en/mongodb.ts +++ b/dataflow/src/i18n/locales/en/mongodb.ts @@ -23,6 +23,7 @@ export const enMongodbMessages = { 'mongodb.collection.deleteDocumentTitle': 'Delete Document', 'mongodb.collection.deleteDocumentMessage': 'Are you sure you want to delete this document? This action cannot be undone.', + 'mongodb.view.selectorLabel': 'Collection view mode', 'mongodb.view.table': 'Table View', 'mongodb.view.json': 'JSON View', 'mongodb.view.switchToTable': 'Switch to Table View', diff --git a/dataflow/src/i18n/locales/zh/mongodb.ts b/dataflow/src/i18n/locales/zh/mongodb.ts index e9ee832ea..db36dd1cf 100644 --- a/dataflow/src/i18n/locales/zh/mongodb.ts +++ b/dataflow/src/i18n/locales/zh/mongodb.ts @@ -15,6 +15,7 @@ export const zhMongodbMessages = { 'mongodb.collection.addData': '新增文档', 'mongodb.collection.deleteDocumentTitle': '删除文档', 'mongodb.collection.deleteDocumentMessage': '确定要删除此文档吗?此操作无法撤销。', + 'mongodb.view.selectorLabel': '集合视图模式', 'mongodb.view.table': '表格视图', 'mongodb.view.json': 'JSON 视图', 'mongodb.view.switchToTable': '切换到表格视图', From 5b87b6f4be980a0fdb47089b5c7321b9577ae7fd Mon Sep 17 00:00:00 2001 From: aimeritething Date: Wed, 3 Jun 2026 14:06:57 +0800 Subject: [PATCH 5/6] update toolbar --- .../CollectionView/CollectionView.Toolbar.tsx | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/dataflow/src/components/database/mongodb/CollectionView/CollectionView.Toolbar.tsx b/dataflow/src/components/database/mongodb/CollectionView/CollectionView.Toolbar.tsx index f54ad3f43..a4a420a25 100644 --- a/dataflow/src/components/database/mongodb/CollectionView/CollectionView.Toolbar.tsx +++ b/dataflow/src/components/database/mongodb/CollectionView/CollectionView.Toolbar.tsx @@ -68,7 +68,7 @@ export function CollectionViewToolbar({ connectionId, databaseName, collectionNa {t('common.actions.refresh')} - + @@ -192,15 +192,13 @@ export function CollectionViewToolbar({ connectionId, databaseName, collectionNa {t('analysis.chart.create')} - - - +
+
-
-
+ actions.setIsFilterModalOpen(true)} count={Object.keys(state.activeFilter).length} @@ -254,7 +252,7 @@ function CollectionViewModeSwitch({ currentMode, onSelect }: CollectionViewModeS return (