diff --git a/frontend/src/components/QueryEditor.external-sql-save.test.tsx b/frontend/src/components/QueryEditor.external-sql-save.test.tsx
index bb9f173..075d0ef 100644
--- a/frontend/src/components/QueryEditor.external-sql-save.test.tsx
+++ b/frontend/src/components/QueryEditor.external-sql-save.test.tsx
@@ -1527,6 +1527,43 @@ describe('QueryEditor external SQL save', () => {
}),
]),
);
+ expect(storeState.updateQueryTabDraft).toHaveBeenCalledWith('tab-1', {
+ formatRestoreSnapshot: {
+ query: 'select * from users where id=1',
+ createdAt: expect.any(Number),
+ },
+ });
+ });
+
+ it('restores the last pre-beautify SQL snapshot after reopening a query tab', async () => {
+ let renderer!: ReactTestRenderer;
+ const originalSql = 'select * from users where id=1';
+
+ await act(async () => {
+ renderer = create(
+ ,
+ );
+ });
+
+ const restoreButton = findButton(renderer, '还原上次美化');
+ await act(async () => {
+ await restoreButton.props.onClick();
+ });
+
+ expect(editorState.value).toBe(originalSql);
+ expect(storeState.updateQueryTabDraft).toHaveBeenCalledWith('tab-1', {
+ query: originalSql,
+ formatRestoreSnapshot: undefined,
+ });
+ expect(messageApi.success).toHaveBeenCalledWith('已还原到美化前 SQL');
});
it('formats postgres window-function SQL with cast syntax through Monaco edits', async () => {
diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx
index 0b8023a..c8e8306 100644
--- a/frontend/src/components/QueryEditor.tsx
+++ b/frontend/src/components/QueryEditor.tsx
@@ -3948,12 +3948,22 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
? connectionsRef.current.find(c => c.id === tabConnectionId)
: undefined);
const formatterLanguage = resolveQueryEditorFormatterLanguage(conn);
- const formatted = format(getCurrentQuery(), { language: formatterLanguage, keywordCase: sqlFormatOptions.keywordCase });
+ const sourceSql = getCurrentQuery();
+ const formatted = format(sourceSql, { language: formatterLanguage, keywordCase: sqlFormatOptions.keywordCase });
+ if (sourceSql === formatted) {
+ return;
+ }
+ updateQueryTabDraft(tab.id, {
+ formatRestoreSnapshot: {
+ query: sourceSql,
+ createdAt: Date.now(),
+ },
+ });
const editor = editorRef.current;
const monaco = monacoRef.current;
const model = editor?.getModel?.();
if (editor && monaco && model) {
- const currentValue = String(model.getValue?.() || '');
+ const currentValue = String(model.getValue?.() || sourceSql);
if (currentValue === formatted) {
return;
}
@@ -3977,6 +3987,21 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
}
};
+ const handleRestoreLastFormat = () => {
+ const previousQuery = tab.formatRestoreSnapshot?.query;
+ if (!previousQuery) {
+ void message.info('没有可还原的美化前 SQL');
+ return;
+ }
+ syncQueryToEditor(previousQuery);
+ updateQueryTabDraft(tab.id, {
+ query: previousQuery,
+ formatRestoreSnapshot: undefined,
+ });
+ refreshObjectDecorations();
+ void message.success('已还原到美化前 SQL');
+ };
+
const handleAIAction = (action: 'generate' | 'explain' | 'optimize' | 'schema') => {
const editor = editorRef.current;
const selection = editor?.getModel()?.getValueInRange(editor.getSelection()) || '';
@@ -4013,6 +4038,13 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
onClick: () => setSqlFormatOptions({ keywordCase: 'lower' })
},
{ type: 'divider' },
+ {
+ key: 'restore-last-format',
+ label: '还原上次美化',
+ disabled: !tab.formatRestoreSnapshot?.query,
+ onClick: handleRestoreLastFormat,
+ },
+ { type: 'divider' },
{
key: 'snippet-settings',
label: '代码片段管理...',
diff --git a/frontend/src/store.test.ts b/frontend/src/store.test.ts
index 4b1a3be..346e8b9 100644
--- a/frontend/src/store.test.ts
+++ b/frontend/src/store.test.ts
@@ -879,6 +879,10 @@ describe('store appearance persistence', () => {
query: 'select * from orders where status = "paid";',
connectionId: 'conn-2',
dbName: 'reporting',
+ formatRestoreSnapshot: {
+ query: 'select * from orders where status="paid";',
+ createdAt: 123,
+ },
});
const persisted = JSON.parse(storage.getItem('lite-db-storage') || '{}');
@@ -890,6 +894,10 @@ describe('store appearance persistence', () => {
connectionId: 'conn-2',
dbName: 'reporting',
query: 'select * from orders where status = "paid";',
+ formatRestoreSnapshot: {
+ query: 'select * from orders where status="paid";',
+ createdAt: 123,
+ },
}),
]);
expect(persisted.state.activeTabId).toBe('query-tab-1');
@@ -903,9 +911,19 @@ describe('store appearance persistence', () => {
connectionId: 'conn-2',
dbName: 'reporting',
query: 'select * from orders where status = "paid";',
+ formatRestoreSnapshot: {
+ query: 'select * from orders where status="paid";',
+ createdAt: 123,
+ },
}),
]);
expect(reloaded.useStore.getState().activeTabId).toBe('query-tab-1');
+
+ reloaded.useStore.getState().updateQueryTabDraft('query-tab-1', {
+ formatRestoreSnapshot: undefined,
+ });
+
+ expect(reloaded.useStore.getState().tabs[0].formatRestoreSnapshot).toBeUndefined();
});
it('updates activeContext when switching between tabs with different host or database', async () => {
diff --git a/frontend/src/store.ts b/frontend/src/store.ts
index fefd976..b6b772b 100644
--- a/frontend/src/store.ts
+++ b/frontend/src/store.ts
@@ -1279,7 +1279,12 @@ interface AppState {
draft: Partial<
Pick<
TabData,
- "query" | "connectionId" | "dbName" | "title" | "resultPanelVisible"
+ | "query"
+ | "connectionId"
+ | "dbName"
+ | "title"
+ | "resultPanelVisible"
+ | "formatRestoreSnapshot"
>
>,
) => void;
@@ -1483,6 +1488,15 @@ const sanitizeQueryTabs = (value: unknown): TabData[] => {
const query = typeof raw.query === "string" ? raw.query.slice(0, MAX_PERSISTED_QUERY_LENGTH) : "";
const filePath = toTrimmedString(raw.filePath);
const savedQueryId = toTrimmedString(raw.savedQueryId);
+ const rawFormatRestoreSnapshot =
+ raw.formatRestoreSnapshot && typeof raw.formatRestoreSnapshot === "object"
+ ? (raw.formatRestoreSnapshot as Record)
+ : null;
+ const formatRestoreQuery =
+ typeof rawFormatRestoreSnapshot?.query === "string"
+ ? rawFormatRestoreSnapshot.query.slice(0, MAX_PERSISTED_QUERY_LENGTH)
+ : "";
+ const formatRestoreCreatedAt = Number(rawFormatRestoreSnapshot?.createdAt);
if (!query.trim() && !filePath && !savedQueryId) return;
let id = toTrimmedString(raw.id, `query-${index + 1}`) || `query-${index + 1}`;
@@ -1505,6 +1519,14 @@ const sanitizeQueryTabs = (value: unknown): TabData[] => {
filePath: filePath || undefined,
savedQueryId: savedQueryId || undefined,
readOnly: raw.readOnly === true,
+ formatRestoreSnapshot: formatRestoreQuery
+ ? {
+ query: formatRestoreQuery,
+ createdAt: Number.isFinite(formatRestoreCreatedAt)
+ ? formatRestoreCreatedAt
+ : Date.now(),
+ }
+ : undefined,
});
});
@@ -2560,6 +2582,30 @@ export const useStore = create()(
changed = true;
}
}
+ if (Object.prototype.hasOwnProperty.call(draft, "formatRestoreSnapshot")) {
+ const rawSnapshot = draft.formatRestoreSnapshot;
+ const nextSnapshot =
+ rawSnapshot && typeof rawSnapshot.query === "string"
+ ? {
+ query: rawSnapshot.query.slice(0, MAX_PERSISTED_QUERY_LENGTH),
+ createdAt: Number.isFinite(Number(rawSnapshot.createdAt))
+ ? Number(rawSnapshot.createdAt)
+ : Date.now(),
+ }
+ : undefined;
+ const currentSnapshot = nextTab.formatRestoreSnapshot;
+ if (
+ currentSnapshot?.query !== nextSnapshot?.query ||
+ currentSnapshot?.createdAt !== nextSnapshot?.createdAt
+ ) {
+ if (nextSnapshot?.query) {
+ nextTab.formatRestoreSnapshot = nextSnapshot;
+ } else {
+ delete nextTab.formatRestoreSnapshot;
+ }
+ changed = true;
+ }
+ }
return nextTab;
});
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index 4fcd731..88927f6 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -436,6 +436,10 @@ export interface TabData {
sidebarLocateKey?: string; // Precise sidebar tree key for locating an object node
savedQueryId?: string; // Saved query identity for quick-save behavior
objectType?: 'table' | 'view' | 'materialized-view'; // Table-like object type for shared viewers
+ formatRestoreSnapshot?: {
+ query: string;
+ createdAt: number;
+ }; // Last SQL content before beautify, for cross-session restore
}
export interface JVMAIPlanContext {