{
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..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,6 +22,14 @@ export const apiClient = {
return window.api.saveConnection(config);
},
+ getWorkspace: async (): Promise
=> {
+ return window.api.getWorkspace();
+ },
+
+ saveWorkspace: async (data: PersistedWorkspace): 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..44ed1e9 100644
--- a/frontend/src/renderer/env.d.ts
+++ b/frontend/src/renderer/env.d.ts
@@ -21,8 +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: import('./types/session').PersistedWorkspace) => 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..fb8a6c8 100644
--- a/frontend/src/renderer/features/table-viewer/TableDataTab.tsx
+++ b/frontend/src/renderer/features/table-viewer/TableDataTab.tsx
@@ -80,6 +80,42 @@ 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/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/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/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/types/session.ts b/frontend/src/renderer/types/session.ts
index 62da675..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;
}
@@ -72,3 +73,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..b863aa4 100644
--- a/frontend/src/renderer/utils/format.ts
+++ b/frontend/src/renderer/utils/format.ts
@@ -15,16 +15,31 @@ 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')
+ 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);
+ }
+ }
+
+ // 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
) {
- display = formatTimestamp(value);
+ return formatTimestamp(value);
}
- return display;
+
+ return String(value);
};