From 9edc9e9d0309130979af0fd67d8003da2287ed3d Mon Sep 17 00:00:00 2001 From: circle33 Date: Fri, 6 Mar 2026 18:33:20 +0800 Subject: [PATCH 1/4] feat(store): implement workspace state persistence and fix JSON/Date formatting --- AGENTS.md | 2 +- frontend/e2e/workspace-persistence.spec.ts | 87 ++++++++++ frontend/src/main/index.ts | 17 ++ frontend/src/main/store.test.ts | 149 ++++++++++++++++++ frontend/src/main/store.ts | 41 +++++ frontend/src/preload/index.ts | 2 + frontend/src/renderer/App.tsx | 125 +++++++++++++-- frontend/src/renderer/api/client.ts | 8 + frontend/src/renderer/env.d.ts | 2 + .../features/table-viewer/TableDataTab.tsx | 23 +++ frontend/src/renderer/layouts/MainLayout.tsx | 74 ++++++++- .../src/renderer/stores/useWorkspaceStore.ts | 8 +- frontend/src/renderer/types/session.ts | 31 ++++ frontend/src/renderer/utils/format.ts | 32 ++-- 14 files changed, 571 insertions(+), 30 deletions(-) create mode 100644 frontend/e2e/workspace-persistence.spec.ts create mode 100644 frontend/src/main/store.test.ts diff --git a/AGENTS.md b/AGENTS.md index 86e294f..76b3280 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,7 +39,7 @@ vstable 是一款专为开发者设计的现代数据库管理工具,支持可 - **Electron Main (`frontend/src/main/`)**: - `daemon.ts`: 管理 Go 后端引擎进程的生命周期(启动、日志记录和停止)。 - `index.ts`: 处理 IPC 路由(如 `db:connect`, `db:query`,并通过 HTTP 代理到 Go 引擎)以及窗口管理。 - - `store.ts`: 处理应用程序配置以及加密凭据的持久化。 + - `store.ts`: 处理应用程序配置、加密凭据以及工作区状态(标签页、会话)的持久化。 - **React Renderer (`frontend/src/renderer/`)**: - `features/`: 包含核心功能模块: - `connection`: 数据库连接表单和管理。 diff --git a/frontend/e2e/workspace-persistence.spec.ts b/frontend/e2e/workspace-persistence.spec.ts new file mode 100644 index 0000000..6b4abda --- /dev/null +++ b/frontend/e2e/workspace-persistence.spec.ts @@ -0,0 +1,87 @@ +import { _electron as electron, expect, test } from '@playwright/test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +test.describe('Workspace Persistence Tests', () => { + let userDataDir: string; + + test.beforeAll(async () => { + userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vstable-e2e-persist-')); + }); + + test.afterAll(async () => { + if (userDataDir && fs.existsSync(userDataDir)) { + try { + fs.rmSync(userDataDir, { recursive: true, force: true }); + } catch (e) {} + } + }); + + test('P-01 Connect, open tabs, and restore after restart', async () => { + // 1. First launch + let electronApp = await electron.launch({ + args: ['.', '--no-sandbox', '--disable-gpu', `--user-data-dir=${userDataDir}`], + }); + let window = await electronApp.firstWindow(); + await window.waitForLoadState('domcontentloaded'); + + // Wait for engine + await expect(async () => { + const response = await window.request.get('http://127.0.0.1:39082/api/ping'); + expect(response.ok()).toBeTruthy(); + }).toPass({ timeout: 15000 }); + + // Connect to PostgreSQL + const form = window.locator('form[data-testid="connection-form"]'); + await expect(form).toBeVisible({ timeout: 10000 }); + + await window.locator('button:has-text("PostgreSQL")').click(); + await window.locator('input[data-testid="input-host"]').fill('127.0.0.1'); + await window.locator('input[data-testid="input-port"]').fill('5433'); + await window.locator('input[data-testid="input-user"]').fill('root'); + await window.locator('input[data-testid="input-password"]').fill('password'); + await window.locator('input[data-testid="input-database"]').fill('vstable_test'); + + await window.locator('button[data-testid="btn-connect"]').click(); + await expect(form).not.toBeVisible({ timeout: 10000 }); + + // Open a Query tab + const mod = os.platform() === 'darwin' ? 'Meta' : 'Control'; + await window.keyboard.press(`${mod}+t`); + + // Ensure the tab appears + await expect(window.locator('div[data-testid="tab-table-New Query"]')).toBeVisible(); + + // Give it a moment to save workspace.json + await window.waitForTimeout(2000); + + // Close the app + await electronApp.close(); + + // 2. Second launch + electronApp = await electron.launch({ + args: ['.', '--no-sandbox', '--disable-gpu', `--user-data-dir=${userDataDir}`], + }); + window = await electronApp.firstWindow(); + await window.waitForLoadState('domcontentloaded'); + + // Wait for engine + await expect(async () => { + const response = await window.request.get('http://127.0.0.1:39082/api/ping'); + expect(response.ok()).toBeTruthy(); + }).toPass({ timeout: 15000 }); + + // form should not be visible (auto connected) + await expect(window.locator('form[data-testid="connection-form"]')).not.toBeVisible({ + timeout: 5000, + }); + + // the tab should be restored + await expect(window.locator('div[data-testid="tab-table-New Query"]')).toBeVisible({ + timeout: 10000, + }); + + await electronApp.close(); + }); +}); diff --git a/frontend/src/main/index.ts b/frontend/src/main/index.ts index 3a00d40..efca120 100644 --- a/frontend/src/main/index.ts +++ b/frontend/src/main/index.ts @@ -146,6 +146,23 @@ handleIPC('store:delete', (_, id) => { store.deleteConnection(id); }); +handleIPC('store:get-workspace', () => { + const workspace = store.getWorkspace(); + // decrypt passwords just like we do for get-all + if (workspace?.sessions) { + workspace.sessions.forEach((s: any) => { + if (s.config?.encryptedPassword) { + s.config.password = store.decryptPassword(s.config.encryptedPassword); + } + }); + } + return workspace; +}); + +handleIPC('store:save-workspace', (_, data) => { + store.saveWorkspace(data); +}); + handleIPC('window:toggle-maximize', (event) => { const win = BrowserWindow.fromWebContents(event.sender); if (win) { diff --git a/frontend/src/main/store.test.ts b/frontend/src/main/store.test.ts new file mode 100644 index 0000000..3c469d9 --- /dev/null +++ b/frontend/src/main/store.test.ts @@ -0,0 +1,149 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + existsSync: vi.fn(), + readFileSync: vi.fn(), + writeFileSync: vi.fn(), +})); + +vi.mock('fs', () => ({ + default: { + existsSync: mocks.existsSync, + readFileSync: mocks.readFileSync, + writeFileSync: mocks.writeFileSync, + }, + existsSync: mocks.existsSync, + readFileSync: mocks.readFileSync, + writeFileSync: mocks.writeFileSync, +})); + +vi.mock('electron', () => ({ + app: { + getPath: vi.fn().mockReturnValue('/mock/userData'), + }, + safeStorage: { + isEncryptionAvailable: vi.fn().mockReturnValue(false), + encryptString: vi.fn(), + decryptString: vi.fn(), + }, +})); + +vi.mock('path', async () => { + const actual = await vi.importActual('path'); + return { + ...actual, + default: { + ...(actual as any), + join: (...args: any[]) => args.join('/'), + }, + join: (...args: any[]) => args.join('/'), + }; +}); + +import { safeStorage } from 'electron'; +// Now import store after mocks +import * as store from './store'; + +describe('Store', () => { + const workspacePath = '/mock/userData/workspace.json'; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('getWorkspace', () => { + it('returns null if file does not exist', () => { + mocks.existsSync.mockReturnValue(false); + const result = store.getWorkspace(); + expect(result).toBeNull(); + expect(mocks.existsSync).toHaveBeenCalledWith(workspacePath); + }); + + it('returns parsed json if file exists', () => { + const mockData = { sessions: [] }; + mocks.existsSync.mockReturnValue(true); + mocks.readFileSync.mockReturnValue(JSON.stringify(mockData)); + + const result = store.getWorkspace(); + expect(result).toEqual(mockData); + }); + + it('returns null if parsing fails', () => { + mocks.existsSync.mockReturnValue(true); + mocks.readFileSync.mockReturnValue('invalid json'); + + const result = store.getWorkspace(); + expect(result).toBeNull(); + }); + }); + + describe('saveWorkspace', () => { + it('saves serialized json to file', () => { + const mockData = { sessions: [] }; + store.saveWorkspace(mockData); + expect(mocks.writeFileSync).toHaveBeenCalledWith( + workspacePath, + JSON.stringify(mockData, null, 2) + ); + }); + + it('strips password when encryption is not available', () => { + (safeStorage.isEncryptionAvailable as any).mockReturnValue(false); + const mockData = { + sessions: [ + { + config: { + password: 'secret_password', + }, + }, + ], + }; + + store.saveWorkspace(mockData); + + const expectedData = { + sessions: [ + { + config: {}, + }, + ], + }; + expect(mocks.writeFileSync).toHaveBeenCalledWith( + workspacePath, + JSON.stringify(expectedData, null, 2) + ); + }); + + it('encrypts password when encryption is available', () => { + (safeStorage.isEncryptionAvailable as any).mockReturnValue(true); + (safeStorage.encryptString as any).mockReturnValue(Buffer.from('encrypted_secret')); + + const mockData = { + sessions: [ + { + config: { + password: 'secret_password', + }, + }, + ], + }; + + store.saveWorkspace(mockData); + + const expectedData = { + sessions: [ + { + config: { + encryptedPassword: Buffer.from('encrypted_secret').toString('base64'), + }, + }, + ], + }; + expect(mocks.writeFileSync).toHaveBeenCalledWith( + workspacePath, + JSON.stringify(expectedData, null, 2) + ); + expect(safeStorage.encryptString).toHaveBeenCalledWith('secret_password'); + }); + }); +}); diff --git a/frontend/src/main/store.ts b/frontend/src/main/store.ts index 40a9b93..c837496 100644 --- a/frontend/src/main/store.ts +++ b/frontend/src/main/store.ts @@ -14,6 +14,7 @@ export interface ConnectionEntry { } const STORE_PATH = join(app.getPath('userData'), 'connections.json'); +const WORKSPACE_PATH = join(app.getPath('userData'), 'workspace.json'); export function getSavedConnections(): ConnectionEntry[] { if (!existsSync(STORE_PATH)) return []; @@ -75,3 +76,43 @@ export function decryptPassword(encryptedBase64: string): string { return ''; } } + +export function getWorkspace(): any { + if (!existsSync(WORKSPACE_PATH)) return null; + try { + const content = readFileSync(WORKSPACE_PATH, 'utf-8'); + return JSON.parse(content); + } catch (e) { + console.error('Failed to read workspace.json', e); + return null; + } +} + +export function saveWorkspace(data: any): void { + try { + // Before saving, we must ensure passwords in configs are encrypted, or simply stripped, + // since connection config is already saved in connections.json. + // It's safer to not persist passwords in workspace.json. + // However, since it's just restoring connection context, removing password is fine + // as connect IPC logic will look it up if we have connection id, or we just rely on the user to re-enter it, + // wait, actually we can just encrypt passwords if they are present. + // Given the architecture, the user might just be connecting via an ad-hoc connection, + // we'll strip raw passwords to be safe and let them use encrypted ones if any. + const safeData = JSON.parse(JSON.stringify(data)); + for (const session of safeData.sessions || []) { + if (session.config) { + if (session.config.password) { + if (safeStorage.isEncryptionAvailable()) { + session.config.encryptedPassword = safeStorage + .encryptString(session.config.password) + .toString('base64'); + } + delete session.config.password; + } + } + } + writeFileSync(WORKSPACE_PATH, JSON.stringify(safeData, null, 2)); + } catch (e) { + console.error('Failed to save workspace.json', e); + } +} diff --git a/frontend/src/preload/index.ts b/frontend/src/preload/index.ts index 8d9b348..4b95b57 100644 --- a/frontend/src/preload/index.ts +++ b/frontend/src/preload/index.ts @@ -18,6 +18,8 @@ if (process.contextIsolated) { getSavedConnections: () => ipcRenderer.invoke('store:get-all'), saveConnection: (config: any) => ipcRenderer.invoke('store:save', config), deleteConnection: (id: string) => ipcRenderer.invoke('store:delete', id), + getWorkspace: () => ipcRenderer.invoke('store:get-workspace'), + saveWorkspace: (data: any) => ipcRenderer.invoke('store:save-workspace', data), }); } catch (error) { console.error(error); diff --git a/frontend/src/renderer/App.tsx b/frontend/src/renderer/App.tsx index 00565b7..bcbdba3 100644 --- a/frontend/src/renderer/App.tsx +++ b/frontend/src/renderer/App.tsx @@ -1,19 +1,114 @@ import { Plus, X } from 'lucide-react'; import type React from 'react'; -import { useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { apiClient } from './api/client'; import { SessionView } from './layouts/MainLayout'; +import type { PersistedSession, PersistedWorkspace } from './types/session'; interface Session { id: string; title: string; + initialConfig?: any; + initialWorkspace?: any; } function App() { - const [sessions, setSessions] = useState([ - { id: crypto.randomUUID(), title: 'New Connection' }, - ]); - const [activeSessionId, setActiveSessionId] = useState(sessions[0].id); + const [sessions, setSessions] = useState([]); + const [activeSessionId, setActiveSessionId] = useState(null); + const [isInitializing, setIsInitializing] = useState(true); + + // Store the latest state of each session reported by SessionView + const sessionStatesRef = useRef>({}); + const saveTimeoutRef = useRef | null>(null); + + // Load workspace on mount + useEffect(() => { + const init = async () => { + try { + const workspace: PersistedWorkspace = await apiClient.getWorkspace(); + if (workspace?.sessions && workspace.sessions.length > 0) { + const loadedSessions: Session[] = workspace.sessions.map((s: PersistedSession) => { + // Restore session state + sessionStatesRef.current[s.id] = { + config: s.config, + tabs: s.tabs, + activeTabId: s.activeTabId, + mruTabIds: s.mruTabIds, + }; + + return { + id: s.id, + title: s.title, + initialConfig: s.config, + initialWorkspace: { + tabs: s.tabs, + activeTabId: s.activeTabId, + mruTabIds: s.mruTabIds, + }, + }; + }); + setSessions(loadedSessions); + setActiveSessionId(workspace.activeSessionId || loadedSessions[0].id); + } else { + // Default empty state + const newSessionId = crypto.randomUUID(); + setSessions([{ id: newSessionId, title: 'New Connection' }]); + setActiveSessionId(newSessionId); + } + } catch (err) { + console.error('Failed to load workspace:', err); + const newSessionId = crypto.randomUUID(); + setSessions([{ id: newSessionId, title: 'New Connection' }]); + setActiveSessionId(newSessionId); + } finally { + setIsInitializing(false); + } + }; + init(); + }, []); + + const saveWorkspace = useCallback(() => { + if (isInitializing || !activeSessionId) return; + + if (saveTimeoutRef.current) { + clearTimeout(saveTimeoutRef.current); + } + + saveTimeoutRef.current = setTimeout(() => { + // Build workspace payload + const payload: PersistedWorkspace = { + activeSessionId, + sessions: sessions.map((s) => { + const state = sessionStatesRef.current[s.id] || {}; + return { + id: s.id, + title: s.title, + config: state.config, + tabs: state.tabs || [], + activeTabId: state.activeTabId || null, + mruTabIds: state.mruTabIds || [], + }; + }), + }; + + apiClient.saveWorkspace(payload).catch((err) => { + console.error('Failed to save workspace:', err); + }); + }, 1000); // 1s debounce + }, [sessions, activeSessionId, isInitializing]); + + // Save workspace when active session changes + useEffect(() => { + saveWorkspace(); + }, [activeSessionId, saveWorkspace]); + + const handleStateChange = useCallback( + (id: string, state: any) => { + sessionStatesRef.current[id] = state; + saveWorkspace(); + }, + [saveWorkspace] + ); const handleAddSession = () => { const newSession = { id: crypto.randomUUID(), title: 'New Connection' }; @@ -23,29 +118,37 @@ function App() { const handleCloseSession = async (e: React.MouseEvent, id: string) => { e.stopPropagation(); - // 关闭连接 await apiClient.disconnect(id); + // Remove from tracked state + delete sessionStatesRef.current[id]; + const newSessions = sessions.filter((s) => s.id !== id); if (newSessions.length === 0) { - // 如果关掉最后一个,创建一个新的 const newSession = { id: crypto.randomUUID(), title: 'New Connection' }; setSessions([newSession]); setActiveSessionId(newSession.id); } else { setSessions(newSessions); if (activeSessionId === id) { - // 如果关闭的是当前激活的,切换到最后一个 setActiveSessionId(newSessions[newSessions.length - 1].id); } } + saveWorkspace(); }; + if (isInitializing) { + return ( +
+ Loading workspace... +
+ ); + } + return (
{/* Titlebar / Tab Bar */}
- {/* Traffic lights area is padded by pl-20 via CSS or Tailwind if configured, keeping it here for safety */}
{sessions.map((session) => (
{ setSessions((prev) => prev.map((s) => (s.id === session.id ? { ...s, title } : s))); + saveWorkspace(); }} /> ))} diff --git a/frontend/src/renderer/api/client.ts b/frontend/src/renderer/api/client.ts index 1e234b2..14ae768 100644 --- a/frontend/src/renderer/api/client.ts +++ b/frontend/src/renderer/api/client.ts @@ -22,6 +22,14 @@ export const apiClient = { return window.api.saveConnection(config); }, + getWorkspace: async (): Promise => { + return window.api.getWorkspace(); + }, + + saveWorkspace: async (data: any): Promise => { + return window.api.saveWorkspace(data); + }, + toggleMaximize: (): void => { window.api.toggleMaximize(); }, diff --git a/frontend/src/renderer/env.d.ts b/frontend/src/renderer/env.d.ts index 050264d..9e9c923 100644 --- a/frontend/src/renderer/env.d.ts +++ b/frontend/src/renderer/env.d.ts @@ -23,6 +23,8 @@ export interface IElectronAPI { getSavedConnections: () => Promise; saveConnection: (config: any) => Promise; deleteConnection: (id: string) => Promise; + getWorkspace: () => Promise; + saveWorkspace: (data: any) => Promise; } declare global { diff --git a/frontend/src/renderer/features/table-viewer/TableDataTab.tsx b/frontend/src/renderer/features/table-viewer/TableDataTab.tsx index 96ae55f..439de6d 100644 --- a/frontend/src/renderer/features/table-viewer/TableDataTab.tsx +++ b/frontend/src/renderer/features/table-viewer/TableDataTab.tsx @@ -80,6 +80,29 @@ export const TableTabPane: React.FC = ({ } }, [tab.name, tab.schema, tab.pk, query, onUpdateTab, capabilities, buildQuery, config.database]); + // Fetch structure if missing + useEffect(() => { + if ((!tab.structure || tab.structure.length === 0) && tab.name && (tab.schema || !capabilities?.supportsSchemas)) { + const fetchStructure = async () => { + const schema = tab.schema || config.database; + const colSql = buildQuery('listColumns', { db: config.database, schema, table: tab.name }); + const res = await query(colSql); + if (res.success && res.rows) { + // Normalize keys to lowercase for consistent access across dialects + const normalizedRows = res.rows.map((row: any) => { + const normalized: any = {}; + Object.keys(row).forEach((key) => { + normalized[key.toLowerCase()] = row[key]; + }); + return normalized; + }); + onUpdateTab({ structure: normalizedRows }); + } + }; + fetchStructure(); + } + }, [tab.name, tab.schema, tab.structure, query, onUpdateTab, capabilities, buildQuery, config.database]); + // Initial fetch useEffect(() => { if (isActive && !tab.results) { diff --git a/frontend/src/renderer/layouts/MainLayout.tsx b/frontend/src/renderer/layouts/MainLayout.tsx index b01b54c..3158dc0 100644 --- a/frontend/src/renderer/layouts/MainLayout.tsx +++ b/frontend/src/renderer/layouts/MainLayout.tsx @@ -24,9 +24,16 @@ interface SessionViewProps { id: string; isActive: boolean; onUpdateTitle: (title: string) => void; + initialConfig?: any; + initialWorkspace?: any; + onStateChange?: (id: string, state: any) => void; } -const SessionContent: React.FC<{ isActive: boolean }> = ({ isActive }) => { +const SessionContent: React.FC<{ + isActive: boolean; + initialConfig?: any; + onStateChange?: (id: string, state: any) => void; +}> = ({ isActive, initialConfig, onStateChange }) => { const { isConnected, config, sessionId, query, buildQuery, connect, disconnect, capabilities } = useSession(); const q = capabilities?.quoteChar || '"'; @@ -70,6 +77,42 @@ const SessionContent: React.FC<{ isActive: boolean }> = ({ isActive }) => { title?: string; } | null>(null); + // Auto connect if initialConfig is provided + useEffect(() => { + if (initialConfig && !isConnected) { + connect(initialConfig); + } + }, [initialConfig, isConnected, connect]); + + // Report state changes + useEffect(() => { + if (onStateChange && isConnected) { + // Create lightweight tabs state + const persistedTabs = tabs.map((t) => ({ + id: t.id, + type: t.type, + name: t.name, + schema: t.schema, + query: t.query, + pk: t.pk, + structure: t.structure, + page: t.page, + pageSize: t.pageSize, + filters: t.filters, + sorts: t.sorts, + mode: t.mode, + initialSchema: t.initialSchema, + initialTableName: t.initialTableName, + })); + onStateChange(sessionId, { + config, + tabs: persistedTabs, + activeTabId, + mruTabIds, + }); + } + }, [isConnected, config, tabs, activeTabId, mruTabIds, sessionId, onStateChange]); + const activeTab = tabs.find((t) => t.id === activeTabId); // Use global shortcuts @@ -361,17 +404,38 @@ const SessionContent: React.FC<{ isActive: boolean }> = ({ isActive }) => { export const SessionView: React.FC = (props) => { return ( - + ); }; -const WorkspaceStoreWrapper: React.FC<{ isActive: boolean }> = ({ isActive }) => { - const [store] = useState(() => createWorkspaceStore()); +interface WorkspaceStoreWrapperProps { + isActive: boolean; + initialConfig?: any; + initialWorkspace?: any; + onStateChange?: (id: string, state: any) => void; +} + +const WorkspaceStoreWrapper: React.FC = ({ + isActive, + initialConfig, + initialWorkspace, + onStateChange, +}) => { + const [store] = useState(() => createWorkspaceStore(initialWorkspace)); return ( - + ); }; diff --git a/frontend/src/renderer/stores/useWorkspaceStore.ts b/frontend/src/renderer/stores/useWorkspaceStore.ts index 2b2a2b7..b277b71 100644 --- a/frontend/src/renderer/stores/useWorkspaceStore.ts +++ b/frontend/src/renderer/stores/useWorkspaceStore.ts @@ -25,11 +25,11 @@ interface WorkspaceState { type WorkspaceStore = ReturnType; -export const createWorkspaceStore = () => { +export const createWorkspaceStore = (initialState?: Partial) => { return createStore((set, get) => ({ - tabs: [], - activeTabId: null, - mruTabIds: [], + tabs: initialState?.tabs || [], + activeTabId: initialState?.activeTabId || null, + mruTabIds: initialState?.mruTabIds || [], showTabSwitcher: false, switcherIndex: 0, diff --git a/frontend/src/renderer/types/session.ts b/frontend/src/renderer/types/session.ts index 62da675..7c36547 100644 --- a/frontend/src/renderer/types/session.ts +++ b/frontend/src/renderer/types/session.ts @@ -72,3 +72,34 @@ export interface FilterCondition { value: string; enabled: boolean; } + +export interface PersistedTab { + id: string; + type: 'table' | 'query' | 'structure'; + name: string; + schema?: string; + query?: string; + pk?: string | null; + structure?: any[]; + page?: number; + pageSize?: number; + filters?: FilterCondition[]; + sorts?: SortCondition[]; + mode?: 'create' | 'edit'; + initialSchema?: string; + initialTableName?: string; +} + +export interface PersistedSession { + id: string; + title: string; + config?: ConnectionConfig; + tabs: PersistedTab[]; + activeTabId: string | null; + mruTabIds: string[]; +} + +export interface PersistedWorkspace { + activeSessionId: string; + sessions: PersistedSession[]; +} diff --git a/frontend/src/renderer/utils/format.ts b/frontend/src/renderer/utils/format.ts index 519b43e..98b5c3b 100644 --- a/frontend/src/renderer/utils/format.ts +++ b/frontend/src/renderer/utils/format.ts @@ -15,16 +15,26 @@ export const formatTimestamp = (value: any) => { }; export const formatDisplayValue = (value: any, dataType?: string) => { - if (value === null) return null; // handle JSX in component - let display = String(value); - if (dataType?.includes('json') && typeof value === 'object') { - display = JSON.stringify(value, null, 2); - } else if ( - dataType?.includes('timestamp') || - dataType?.includes('date') || - dataType?.includes('time') - ) { - display = formatTimestamp(value); + if (value === null || value === undefined) return null; + + const type = dataType?.toLowerCase() || ''; + + // If it's a JSON type, handle potential string-encoded JSON or direct objects + if (type.includes('json')) { + try { + // If it's a string that looks like JSON, try to parse it first to avoid extra escaping + const parsed = typeof value === 'string' ? JSON.parse(value) : value; + return JSON.stringify(parsed, null, 2); + } catch (e) { + // If parsing fails (e.g. it's just a regular string in a json column), return as-is + return String(value); + } } - return display; + + // If it's a date/time, use our custom formatter to avoid local timezone strings + if (type.includes('timestamp') || type.includes('date') || type.includes('time') || value instanceof Date) { + return formatTimestamp(value); + } + + return String(value); }; From af13a5bcac7fc84319d4d3a1f6cda8af4cb2982f Mon Sep 17 00:00:00 2001 From: circle33 Date: Fri, 6 Mar 2026 10:53:29 +0000 Subject: [PATCH 2/4] test: fix frontend unit tests --- frontend/e2e/AGENTS.md | 3 ++ frontend/e2e/workspace-persistence.spec.ts | 47 +++++++++++++++---- frontend/src/main/index.ts | 4 +- frontend/src/main/store.test.ts | 6 ++- frontend/src/main/store.ts | 5 +- frontend/src/renderer/App.test.tsx | 20 ++++---- .../features/table-viewer/TableDataTab.tsx | 17 ++++++- frontend/src/renderer/test/setup.tsx | 2 + frontend/src/renderer/utils/format.ts | 7 ++- 9 files changed, 84 insertions(+), 27 deletions(-) diff --git a/frontend/e2e/AGENTS.md b/frontend/e2e/AGENTS.md index 41bb5af..14cc682 100644 --- a/frontend/e2e/AGENTS.md +++ b/frontend/e2e/AGENTS.md @@ -44,3 +44,6 @@ **Resilience and Errors** - R-01 Constraint Violation: 尝试插入重复主键或非法格式(如 UUID 列填入普通字符串),验证 AlertModal 报错。 - R-02 Connection Failure: 输入错误的凭据,验证 UI 显示明确的错误提示。 + +**Workspace Persistence** +- P-01 Restore Session: 连接数据库并打开页签,执行创建表格 SQL,打开表格后重启应用,验证自动连接并恢复之前的页签状态。 diff --git a/frontend/e2e/workspace-persistence.spec.ts b/frontend/e2e/workspace-persistence.spec.ts index 6b4abda..ea612bf 100644 --- a/frontend/e2e/workspace-persistence.spec.ts +++ b/frontend/e2e/workspace-persistence.spec.ts @@ -46,15 +46,44 @@ test.describe('Workspace Persistence Tests', () => { await window.locator('button[data-testid="btn-connect"]').click(); await expect(form).not.toBeVisible({ timeout: 10000 }); - // Open a Query tab + // Create a table first to ensure it exists const mod = os.platform() === 'darwin' ? 'Meta' : 'Control'; await window.keyboard.press(`${mod}+t`); + const activeTab = window.locator('div[data-testid="active-tab-content"]'); + const editor = activeTab.locator('.monaco-editor').last(); + await editor.click(); + await window.keyboard.press(`${mod}+a`); + await window.keyboard.press('Backspace'); + await window.keyboard.insertText('CREATE TABLE IF NOT EXISTS persist_test (id int);'); + await activeTab.locator('button[data-testid="btn-run-query"]').click(); + await expect(activeTab.locator('text=Loading data...')).not.toBeVisible({ timeout: 10000 }); + + // Open the table tab + await window.locator('button[data-testid="btn-refresh-tables"]').click(); + const tableItem = window.locator('div[data-testid="table-item-persist_test"]'); + await expect(tableItem).toBeVisible({ timeout: 10000 }); + await tableItem.click(); // Ensure the tab appears - await expect(window.locator('div[data-testid="tab-table-New Query"]')).toBeVisible(); + await expect(window.locator('div[data-testid="tab-table-persist_test"]')).toBeVisible(); - // Give it a moment to save workspace.json - await window.waitForTimeout(2000); + // Wait for the data to finish loading to ensure all state updates are done + const activeTabContent = window.locator('div[data-testid="active-tab-content"]'); + await expect(activeTabContent.locator('text=Loading data...')).not.toBeVisible({ + timeout: 10000, + }); + + // Give it a moment to save workspace.json (CI can be slow, 1s debounce + IO) + const workspacePath = path.join(userDataDir, 'workspace.json'); + await expect(async () => { + expect(fs.existsSync(workspacePath)).toBeTruthy(); + const content = fs.readFileSync(workspacePath, 'utf8'); + const data = JSON.parse(content); + const hasTableTab = data.sessions?.[0]?.tabs?.some( + (t: any) => t.type === 'table' && t.name === 'persist_test' + ); + expect(hasTableTab).toBeTruthy(); + }).toPass({ timeout: 15000 }); // Close the app await electronApp.close(); @@ -66,19 +95,17 @@ test.describe('Workspace Persistence Tests', () => { window = await electronApp.firstWindow(); await window.waitForLoadState('domcontentloaded'); + // Wait for "Loading workspace..." to disappear + await expect(window.locator('text=Loading workspace...')).not.toBeVisible({ timeout: 15000 }); + // Wait for engine await expect(async () => { const response = await window.request.get('http://127.0.0.1:39082/api/ping'); expect(response.ok()).toBeTruthy(); }).toPass({ timeout: 15000 }); - // form should not be visible (auto connected) - await expect(window.locator('form[data-testid="connection-form"]')).not.toBeVisible({ - timeout: 5000, - }); - // the tab should be restored - await expect(window.locator('div[data-testid="tab-table-New Query"]')).toBeVisible({ + await expect(window.locator('div[data-testid="tab-table-persist_test"]')).toBeVisible({ timeout: 10000, }); diff --git a/frontend/src/main/index.ts b/frontend/src/main/index.ts index efca120..2972794 100644 --- a/frontend/src/main/index.ts +++ b/frontend/src/main/index.ts @@ -134,7 +134,7 @@ handleIPC('store:get-all', () => { // 返回时解密密码,方便前端填充(仅在 IPC 通道传输) return connections.map((c) => ({ ...c, - password: c.encryptedPassword ? store.decryptPassword(c.encryptedPassword) : '', + password: c.encryptedPassword ? store.decryptPassword(c.encryptedPassword) : c.password || '', })); }); @@ -154,6 +154,8 @@ handleIPC('store:get-workspace', () => { if (s.config?.encryptedPassword) { s.config.password = store.decryptPassword(s.config.encryptedPassword); } + // If encryptedPassword is not present, it means password was saved as plain text or is empty. + // It is already in s.config.password from JSON if plain text, so no action needed. }); } return workspace; diff --git a/frontend/src/main/store.test.ts b/frontend/src/main/store.test.ts index 3c469d9..c0f6799 100644 --- a/frontend/src/main/store.test.ts +++ b/frontend/src/main/store.test.ts @@ -87,7 +87,7 @@ describe('Store', () => { ); }); - it('strips password when encryption is not available', () => { + it('keeps plain text password when encryption is not available', () => { (safeStorage.isEncryptionAvailable as any).mockReturnValue(false); const mockData = { sessions: [ @@ -104,7 +104,9 @@ describe('Store', () => { const expectedData = { sessions: [ { - config: {}, + config: { + password: 'secret_password', + }, }, ], }; diff --git a/frontend/src/main/store.ts b/frontend/src/main/store.ts index c837496..a259e97 100644 --- a/frontend/src/main/store.ts +++ b/frontend/src/main/store.ts @@ -11,6 +11,7 @@ export interface ConnectionEntry { user: string; database: string; encryptedPassword?: string; + password?: string; } const STORE_PATH = join(app.getPath('userData'), 'connections.json'); @@ -49,6 +50,7 @@ export function saveConnection(config: any): void { user: config.user, database: config.database, encryptedPassword: encryptedPassword || undefined, + password: !safeStorage.isEncryptionAvailable() && config.password ? config.password : undefined, }; const index = connections.findIndex((c) => c.id === entry.id); @@ -106,8 +108,9 @@ export function saveWorkspace(data: any): void { session.config.encryptedPassword = safeStorage .encryptString(session.config.password) .toString('base64'); + delete session.config.password; } - delete session.config.password; + // If encryption is not available, we KEEP session.config.password as plain text! } } } diff --git a/frontend/src/renderer/App.test.tsx b/frontend/src/renderer/App.test.tsx index 9dbced2..fbe8fb6 100644 --- a/frontend/src/renderer/App.test.tsx +++ b/frontend/src/renderer/App.test.tsx @@ -2,25 +2,25 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import App from './App'; describe('App Component', () => { - it('renders with a default session', () => { + it('renders with a default session', async () => { render(); - expect(screen.getByText('New Connection')).toBeInTheDocument(); - expect(screen.getAllByText('Connect to PostgreSQL')).toHaveLength(1); + expect(await screen.findByText('New Connection')).toBeInTheDocument(); + expect(await screen.findAllByText('Connect to PostgreSQL')).toHaveLength(1); }); - it('can add a new session', () => { + it('can add a new session', async () => { render(); - const addButton = screen.getByTestId('add-session-btn'); + const addButton = await screen.findByTestId('add-session-btn'); fireEvent.click(addButton); - const tabs = screen.getAllByTestId('session-tab'); + const tabs = await screen.findAllByTestId('session-tab'); expect(tabs).toHaveLength(2); }); - it('ensures add button is outside the scrollable tab container', () => { + it('ensures add button is outside the scrollable tab container', async () => { render(); - const addButton = screen.getByTestId('add-session-btn'); + const addButton = await screen.findByTestId('add-session-btn'); const titlebar = addButton.closest('.titlebar'); const scrollContainer = titlebar?.querySelector('.overflow-x-auto'); @@ -32,10 +32,10 @@ describe('App Component', () => { it('switches active session when clicking tabs', async () => { render(); - const addButton = screen.getByTestId('add-session-btn'); + const addButton = await screen.findByTestId('add-session-btn'); fireEvent.click(addButton); // Now we have 2 sessions - const tabs = screen.getAllByTestId('session-tab'); + const tabs = await screen.findAllByTestId('session-tab'); expect(tabs).toHaveLength(2); const session1Tab = tabs[0]; const session2Tab = tabs[1]; diff --git a/frontend/src/renderer/features/table-viewer/TableDataTab.tsx b/frontend/src/renderer/features/table-viewer/TableDataTab.tsx index 439de6d..fb8a6c8 100644 --- a/frontend/src/renderer/features/table-viewer/TableDataTab.tsx +++ b/frontend/src/renderer/features/table-viewer/TableDataTab.tsx @@ -82,7 +82,11 @@ export const TableTabPane: React.FC = ({ // Fetch structure if missing useEffect(() => { - if ((!tab.structure || tab.structure.length === 0) && tab.name && (tab.schema || !capabilities?.supportsSchemas)) { + if ( + (!tab.structure || tab.structure.length === 0) && + tab.name && + (tab.schema || !capabilities?.supportsSchemas) + ) { const fetchStructure = async () => { const schema = tab.schema || config.database; const colSql = buildQuery('listColumns', { db: config.database, schema, table: tab.name }); @@ -101,7 +105,16 @@ export const TableTabPane: React.FC = ({ }; fetchStructure(); } - }, [tab.name, tab.schema, tab.structure, query, onUpdateTab, capabilities, buildQuery, config.database]); + }, [ + tab.name, + tab.schema, + tab.structure, + query, + onUpdateTab, + capabilities, + buildQuery, + config.database, + ]); // Initial fetch useEffect(() => { diff --git a/frontend/src/renderer/test/setup.tsx b/frontend/src/renderer/test/setup.tsx index 9e6ddd8..975d7f5 100644 --- a/frontend/src/renderer/test/setup.tsx +++ b/frontend/src/renderer/test/setup.tsx @@ -43,6 +43,8 @@ window.api = { getSavedConnections: vi.fn().mockResolvedValue([]), deleteConnection: vi.fn().mockResolvedValue(true), saveConnection: vi.fn().mockResolvedValue(true), + getWorkspace: vi.fn().mockResolvedValue(null), + saveWorkspace: vi.fn().mockResolvedValue(true), generateAlterSql: vi.fn().mockResolvedValue(['ALTER TABLE "users" ADD COLUMN "age" integer;']), generateCreateSql: vi.fn().mockResolvedValue(['CREATE TABLE "users" ("id" serial PRIMARY KEY);']), }; diff --git a/frontend/src/renderer/utils/format.ts b/frontend/src/renderer/utils/format.ts index 98b5c3b..b863aa4 100644 --- a/frontend/src/renderer/utils/format.ts +++ b/frontend/src/renderer/utils/format.ts @@ -32,7 +32,12 @@ export const formatDisplayValue = (value: any, dataType?: string) => { } // If it's a date/time, use our custom formatter to avoid local timezone strings - if (type.includes('timestamp') || type.includes('date') || type.includes('time') || value instanceof Date) { + if ( + type.includes('timestamp') || + type.includes('date') || + type.includes('time') || + value instanceof Date + ) { return formatTimestamp(value); } From 7c69cc7dd066541d193e4ce55bd1bd8def2c06ef Mon Sep 17 00:00:00 2001 From: circle33 Date: Sun, 8 Mar 2026 00:22:42 +0800 Subject: [PATCH 3/4] refactor(store): replace any with specific types for workspace and connection store --- frontend/src/main/store.ts | 7 ++++--- frontend/src/preload/index.ts | 5 +++-- frontend/src/renderer/api/client.ts | 6 +++--- frontend/src/renderer/env.d.ts | 6 +++--- 4 files changed, 13 insertions(+), 11 deletions(-) diff --git a/frontend/src/main/store.ts b/frontend/src/main/store.ts index a259e97..bc84235 100644 --- a/frontend/src/main/store.ts +++ b/frontend/src/main/store.ts @@ -1,6 +1,7 @@ import { app, safeStorage } from 'electron'; import { existsSync, readFileSync, writeFileSync } from 'fs'; import { join } from 'path'; +import type { ConnectionConfig, PersistedWorkspace } from '../renderer/types/session'; export interface ConnectionEntry { id: string; @@ -32,7 +33,7 @@ export function getSavedConnections(): ConnectionEntry[] { } } -export function saveConnection(config: any): void { +export function saveConnection(config: ConnectionConfig): void { const connections = getSavedConnections(); // 处理加密 @@ -79,7 +80,7 @@ export function decryptPassword(encryptedBase64: string): string { } } -export function getWorkspace(): any { +export function getWorkspace(): PersistedWorkspace | null { if (!existsSync(WORKSPACE_PATH)) return null; try { const content = readFileSync(WORKSPACE_PATH, 'utf-8'); @@ -90,7 +91,7 @@ export function getWorkspace(): any { } } -export function saveWorkspace(data: any): void { +export function saveWorkspace(data: PersistedWorkspace): void { try { // Before saving, we must ensure passwords in configs are encrypted, or simply stripped, // since connection config is already saved in connections.json. diff --git a/frontend/src/preload/index.ts b/frontend/src/preload/index.ts index 4b95b57..916280e 100644 --- a/frontend/src/preload/index.ts +++ b/frontend/src/preload/index.ts @@ -1,5 +1,6 @@ import { exposeElectronAPI } from '@electron-toolkit/preload'; import { contextBridge, ipcRenderer } from 'electron'; +import type { ConnectionConfig, PersistedWorkspace } from '../renderer/types/session'; // 暴露出基础的 Electron API if (process.contextIsolated) { @@ -16,10 +17,10 @@ if (process.contextIsolated) { toggleMaximize: () => ipcRenderer.invoke('window:toggle-maximize'), // Store APIs getSavedConnections: () => ipcRenderer.invoke('store:get-all'), - saveConnection: (config: any) => ipcRenderer.invoke('store:save', config), + saveConnection: (config: ConnectionConfig) => ipcRenderer.invoke('store:save', config), deleteConnection: (id: string) => ipcRenderer.invoke('store:delete', id), getWorkspace: () => ipcRenderer.invoke('store:get-workspace'), - saveWorkspace: (data: any) => ipcRenderer.invoke('store:save-workspace', data), + saveWorkspace: (data: PersistedWorkspace) => ipcRenderer.invoke('store:save-workspace', data), }); } catch (error) { console.error(error); diff --git a/frontend/src/renderer/api/client.ts b/frontend/src/renderer/api/client.ts index 14ae768..39ad4fb 100644 --- a/frontend/src/renderer/api/client.ts +++ b/frontend/src/renderer/api/client.ts @@ -1,4 +1,4 @@ -import type { ConnectionConfig, QueryResult } from '../types/session'; +import type { ConnectionConfig, PersistedWorkspace, QueryResult } from '../types/session'; /** * API Client Layer @@ -22,11 +22,11 @@ export const apiClient = { return window.api.saveConnection(config); }, - getWorkspace: async (): Promise => { + getWorkspace: async (): Promise => { return window.api.getWorkspace(); }, - saveWorkspace: async (data: any): Promise => { + saveWorkspace: async (data: PersistedWorkspace): Promise => { return window.api.saveWorkspace(data); }, diff --git a/frontend/src/renderer/env.d.ts b/frontend/src/renderer/env.d.ts index 9e9c923..44ed1e9 100644 --- a/frontend/src/renderer/env.d.ts +++ b/frontend/src/renderer/env.d.ts @@ -21,10 +21,10 @@ export interface IElectronAPI { // Store getSavedConnections: () => Promise; - saveConnection: (config: any) => Promise; + saveConnection: (config: import('./types/session').ConnectionConfig) => Promise; deleteConnection: (id: string) => Promise; - getWorkspace: () => Promise; - saveWorkspace: (data: any) => Promise; + getWorkspace: () => Promise; + saveWorkspace: (data: import('./types/session').PersistedWorkspace) => Promise; } declare global { From e3cf565a3431aac19275d13d4e424c5d5e66be92 Mon Sep 17 00:00:00 2001 From: circle33 Date: Sun, 8 Mar 2026 10:02:20 +0800 Subject: [PATCH 4/4] feat(store): strip plaintext passwords from workspace.json and restore from connections.json --- frontend/src/main/store.test.ts | 5 +++-- frontend/src/main/store.ts | 3 +-- frontend/src/renderer/App.tsx | 12 +++++++++++- frontend/src/renderer/stores/useSessionStore.tsx | 6 ++++++ frontend/src/renderer/types/session.ts | 1 + 5 files changed, 22 insertions(+), 5 deletions(-) diff --git a/frontend/src/main/store.test.ts b/frontend/src/main/store.test.ts index c0f6799..b1921ad 100644 --- a/frontend/src/main/store.test.ts +++ b/frontend/src/main/store.test.ts @@ -87,12 +87,13 @@ describe('Store', () => { ); }); - it('keeps plain text password when encryption is not available', () => { + it('completely strips password when encryption is not available', () => { (safeStorage.isEncryptionAvailable as any).mockReturnValue(false); const mockData = { sessions: [ { config: { + id: 'conn-1', password: 'secret_password', }, }, @@ -105,7 +106,7 @@ describe('Store', () => { sessions: [ { config: { - password: 'secret_password', + id: 'conn-1', }, }, ], diff --git a/frontend/src/main/store.ts b/frontend/src/main/store.ts index bc84235..4d75bf4 100644 --- a/frontend/src/main/store.ts +++ b/frontend/src/main/store.ts @@ -109,9 +109,8 @@ export function saveWorkspace(data: PersistedWorkspace): void { session.config.encryptedPassword = safeStorage .encryptString(session.config.password) .toString('base64'); - delete session.config.password; } - // If encryption is not available, we KEEP session.config.password as plain text! + delete session.config.password; } } } diff --git a/frontend/src/renderer/App.tsx b/frontend/src/renderer/App.tsx index bcbdba3..268716b 100644 --- a/frontend/src/renderer/App.tsx +++ b/frontend/src/renderer/App.tsx @@ -25,9 +25,19 @@ function App() { useEffect(() => { const init = async () => { try { - const workspace: PersistedWorkspace = await apiClient.getWorkspace(); + const workspace = await apiClient.getWorkspace(); + const savedConnections = await (window as any).api.getSavedConnections(); + if (workspace?.sessions && workspace.sessions.length > 0) { const loadedSessions: Session[] = workspace.sessions.map((s: PersistedSession) => { + // Restore password from saved connections if missing + if (s.config && !s.config.password && !s.config.encryptedPassword) { + const match = savedConnections.find((c: any) => c.id === s.config!.id); + if (match?.password) { + s.config.password = match.password; + } + } + // Restore session state sessionStatesRef.current[s.id] = { config: s.config, diff --git a/frontend/src/renderer/stores/useSessionStore.tsx b/frontend/src/renderer/stores/useSessionStore.tsx index 7efbf6e..6488f7e 100644 --- a/frontend/src/renderer/stores/useSessionStore.tsx +++ b/frontend/src/renderer/stores/useSessionStore.tsx @@ -118,6 +118,12 @@ export const createSessionStore = (id: string, onUpdateTitle: (title: string) => connect: async (newConfig: ConnectionConfig) => { set({ loading: true, error: null }); + + // Generate ID if missing so it is reliably matched during workspace restore + if (!newConfig.id) { + newConfig.id = crypto.randomUUID(); + } + try { const result = await apiClient.connect(id, newConfig); if (result.success) { diff --git a/frontend/src/renderer/types/session.ts b/frontend/src/renderer/types/session.ts index 7c36547..6f83d9b 100644 --- a/frontend/src/renderer/types/session.ts +++ b/frontend/src/renderer/types/session.ts @@ -34,6 +34,7 @@ export interface ConnectionConfig { port: number; user: string; password?: string; + encryptedPassword?: string; database: string; }