mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-06 10:31:30 +08:00
✨ feat(sql-editor): 关闭应用时提示保存未保存SQL
- 关闭应用前拦截 Wails 退出事件,前端弹出确认退出、保存退出和取消三按钮\n- 保存退出支持外部 SQL 文件和已保存查询,未命名临时查询保留草稿恢复语义\n- 补充退出保护后端测试、前端保存目标测试和多语言文案
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||
import { Layout, Button, ConfigProvider, theme, message, Spin, Slider, Progress, Switch, Input, InputNumber, Select, Segmented, Tooltip } from 'antd';
|
||||
import { PlusOutlined, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, GlobalOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined, LinkOutlined, BgColorsOutlined, AppstoreOutlined, RobotOutlined, FolderOpenOutlined, HddOutlined, SafetyCertificateOutlined, SwitcherOutlined, CodeOutlined, RightOutlined } from '@ant-design/icons';
|
||||
import { BrowserOpenURL, Environment, Quit, WindowFullscreen, WindowGetPosition, WindowGetSize, WindowIsFullscreen, WindowIsMaximised, WindowIsMinimised, WindowIsNormal, WindowMaximise, WindowMinimise, WindowSetPosition, WindowSetSize, WindowUnfullscreen, WindowUnmaximise } from '../wailsjs/runtime';
|
||||
import { BrowserOpenURL, Environment, EventsOn, WindowFullscreen, WindowGetPosition, WindowGetSize, WindowIsFullscreen, WindowIsMaximised, WindowIsMinimised, WindowIsNormal, WindowMaximise, WindowMinimise, WindowSetPosition, WindowSetSize, WindowUnfullscreen, WindowUnmaximise } from '../wailsjs/runtime';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import TabManager from './components/TabManager';
|
||||
import ConnectionModal from './components/ConnectionModal';
|
||||
@@ -109,11 +109,16 @@ import {
|
||||
} from './utils/aiEntryLayout';
|
||||
import { DEFAULT_AI_PANEL_WIDTH, resolveOverlayAIPanelWidth, shouldOverlayAIPanel } from './utils/aiPanelLayout';
|
||||
import { safeWindowRuntimeCall } from './utils/wailsRuntime';
|
||||
import {
|
||||
buildApplicationQuitUnsavedSQLLabel,
|
||||
collectApplicationQuitUnsavedSQLTargets,
|
||||
saveApplicationQuitUnsavedSQLTargets,
|
||||
} from './utils/sqlEditorApplicationQuit';
|
||||
import { useAppUpdateManager } from './hooks/useAppUpdateManager';
|
||||
import { useAppLogPanelResize } from './hooks/useAppLogPanelResize';
|
||||
import { useAppSidebarResize } from './hooks/useAppSidebarResize';
|
||||
import { useAppUtilityStyles } from './hooks/useAppUtilityStyles';
|
||||
import { ApplyDataRootDirectory, GetDataRootDirectoryInfo, GetSavedConnections, ListInstalledFontFamilies, OpenDataRootDirectory, SelectDataRootDirectory, SetMacNativeWindowControls, SetWindowTranslucency } from '../wailsjs/go/app/App';
|
||||
import { ApplyDataRootDirectory, CancelApplicationQuit, ForceQuitApplication, GetDataRootDirectoryInfo, GetSavedConnections, ListInstalledFontFamilies, OpenDataRootDirectory, SelectDataRootDirectory, SetMacNativeWindowControls, SetWindowTranslucency } from '../wailsjs/go/app/App';
|
||||
import { getAntdLocale } from './i18n/frameworkLocale';
|
||||
import { useI18n } from './i18n/provider';
|
||||
import './App.css';
|
||||
@@ -1258,6 +1263,10 @@ function App() {
|
||||
const tabs = useStore(state => state.tabs);
|
||||
const activeTabId = useStore(state => state.activeTabId);
|
||||
const setActiveTab = useStore(state => state.setActiveTab);
|
||||
const savedQueries = useStore(state => state.savedQueries);
|
||||
const saveQuery = useStore(state => state.saveQuery);
|
||||
const applicationQuitConfirmRef = useRef<{ destroy: () => void } | null>(null);
|
||||
const applicationQuitHandlingRef = useRef(false);
|
||||
const openSecurityUpdateSettings = useCallback((focusTarget: SecurityUpdateSettingsFocusTarget | null = null) => {
|
||||
setIsSecurityUpdateIntroOpen(false);
|
||||
setSecurityUpdateSettingsFocusTarget(focusTarget);
|
||||
@@ -1732,6 +1741,110 @@ function App() {
|
||||
setActiveTab(tabs[nextIndex].id);
|
||||
}, [activeTabId, setActiveTab, tabs]);
|
||||
|
||||
const resetApplicationQuitRequest = useCallback(() => {
|
||||
applicationQuitHandlingRef.current = false;
|
||||
applicationQuitConfirmRef.current = null;
|
||||
void CancelApplicationQuit();
|
||||
}, []);
|
||||
|
||||
const forceQuitApplication = useCallback(async () => {
|
||||
const res = await ForceQuitApplication();
|
||||
if (res && res.success === false) {
|
||||
throw new Error(res.message || t('common.unknown'));
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const handleApplicationQuitRequest = useCallback(async () => {
|
||||
if (applicationQuitHandlingRef.current) {
|
||||
return;
|
||||
}
|
||||
applicationQuitHandlingRef.current = true;
|
||||
|
||||
let targets;
|
||||
try {
|
||||
targets = await collectApplicationQuitUnsavedSQLTargets(tabs, savedQueries);
|
||||
} catch (error) {
|
||||
resetApplicationQuitRequest();
|
||||
message.error(t('app.quit.unsaved_sql.inspect_failed', {
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (targets.length === 0) {
|
||||
try {
|
||||
await forceQuitApplication();
|
||||
} catch (error) {
|
||||
resetApplicationQuitRequest();
|
||||
message.error(t('app.quit.message.quit_failed', {
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
}));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const label = buildApplicationQuitUnsavedSQLLabel(targets);
|
||||
let destroyConfirm: (() => void) | null = null;
|
||||
const confirmRef = Modal.confirm({
|
||||
title: t('app.quit.unsaved_sql.title'),
|
||||
content: t(targets.length === 1
|
||||
? 'app.quit.unsaved_sql.content_single'
|
||||
: 'app.quit.unsaved_sql.content_multiple', { label }),
|
||||
okText: t('app.quit.unsaved_sql.save_exit'),
|
||||
cancelText: t('app.quit.unsaved_sql.cancel'),
|
||||
closable: true,
|
||||
maskClosable: false,
|
||||
okButtonProps: { danger: true, type: 'primary' },
|
||||
footer: (_, { OkBtn, CancelBtn }) => (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => {
|
||||
destroyConfirm?.();
|
||||
applicationQuitConfirmRef.current = null;
|
||||
void forceQuitApplication().catch((error) => {
|
||||
resetApplicationQuitRequest();
|
||||
message.error(t('app.quit.message.quit_failed', {
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
}));
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t('app.quit.unsaved_sql.confirm_exit')}
|
||||
</Button>
|
||||
<CancelBtn />
|
||||
<OkBtn />
|
||||
</>
|
||||
),
|
||||
onCancel: () => {
|
||||
resetApplicationQuitRequest();
|
||||
},
|
||||
onOk: async () => {
|
||||
try {
|
||||
await saveApplicationQuitUnsavedSQLTargets(targets, saveQuery);
|
||||
message.success(t('app.quit.unsaved_sql.saved'));
|
||||
await forceQuitApplication();
|
||||
} catch (error) {
|
||||
resetApplicationQuitRequest();
|
||||
message.error(t('app.quit.unsaved_sql.save_failed_cancel_exit', {
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
}));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
});
|
||||
destroyConfirm = confirmRef.destroy;
|
||||
applicationQuitConfirmRef.current = confirmRef;
|
||||
}, [forceQuitApplication, resetApplicationQuitRequest, saveQuery, savedQueries, t, tabs]);
|
||||
|
||||
useEffect(() => {
|
||||
const offBeforeClose = EventsOn('app:before-close-request', () => {
|
||||
void handleApplicationQuitRequest();
|
||||
});
|
||||
return () => {
|
||||
offBeforeClose();
|
||||
};
|
||||
}, [handleApplicationQuitRequest]);
|
||||
|
||||
const closeConnectionPackageDialog = useCallback(() => {
|
||||
setConnectionPackageDialog(createClosedConnectionPackageDialogState());
|
||||
setPendingConnectionImportPayload(null);
|
||||
@@ -2955,7 +3068,7 @@ function App() {
|
||||
danger
|
||||
className="titlebar-close-btn"
|
||||
style={{ height: '100%', borderRadius: 0, width: titleBarButtonWidth }}
|
||||
onClick={Quit}
|
||||
onClick={() => { void handleApplicationQuitRequest(); }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
120
frontend/src/utils/sqlEditorApplicationQuit.test.ts
Normal file
120
frontend/src/utils/sqlEditorApplicationQuit.test.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { SavedQuery, TabData } from '../types';
|
||||
import { clearQueryTabDraft, setQueryTabDraft, setSQLFileTabDraft } from './sqlFileTabDrafts';
|
||||
import {
|
||||
collectApplicationQuitUnsavedSQLTargets,
|
||||
saveApplicationQuitUnsavedSQLTargets,
|
||||
} from './sqlEditorApplicationQuit';
|
||||
|
||||
const createQueryTab = (overrides: Partial<TabData>): TabData => ({
|
||||
id: 'tab-1',
|
||||
title: 'Query',
|
||||
type: 'query',
|
||||
connectionId: 'conn-1',
|
||||
dbName: 'main',
|
||||
query: '',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createSavedQuery = (overrides: Partial<SavedQuery> = {}): SavedQuery => ({
|
||||
id: 'saved-1',
|
||||
name: 'Saved query',
|
||||
sql: 'select 1;',
|
||||
connectionId: 'conn-1',
|
||||
dbName: 'main',
|
||||
createdAt: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('sqlEditorApplicationQuit', () => {
|
||||
beforeEach(() => {
|
||||
clearQueryTabDraft('tab-1');
|
||||
clearQueryTabDraft('tab-2');
|
||||
clearQueryTabDraft('tab-3');
|
||||
});
|
||||
|
||||
it('collects dirty external SQL file tabs by comparing the draft with disk content', async () => {
|
||||
const tab = createQueryTab({
|
||||
id: 'tab-1',
|
||||
title: 'work_order.sql',
|
||||
filePath: '/tmp/work_order.sql',
|
||||
query: 'select * from old_table;',
|
||||
});
|
||||
setSQLFileTabDraft('tab-1', 'select * from mes_work_order;');
|
||||
const readSQLFile = vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
data: { content: 'select * from old_table;' },
|
||||
});
|
||||
|
||||
const targets = await collectApplicationQuitUnsavedSQLTargets([tab], [], readSQLFile);
|
||||
|
||||
expect(targets).toEqual([expect.objectContaining({
|
||||
kind: 'sql-file',
|
||||
tabId: 'tab-1',
|
||||
title: 'work_order.sql',
|
||||
filePath: '/tmp/work_order.sql',
|
||||
draft: 'select * from mes_work_order;',
|
||||
})]);
|
||||
});
|
||||
|
||||
it('collects dirty saved-query tabs and ignores unnamed temporary query tabs', async () => {
|
||||
const savedQuery = createSavedQuery();
|
||||
const savedTab = createQueryTab({
|
||||
id: 'tab-2',
|
||||
title: 'Saved query',
|
||||
savedQueryId: 'saved-1',
|
||||
query: 'select 1;',
|
||||
});
|
||||
const unnamedTab = createQueryTab({
|
||||
id: 'tab-3',
|
||||
title: 'New query',
|
||||
query: 'select * from draft_only;',
|
||||
});
|
||||
setQueryTabDraft('tab-2', 'select 2;');
|
||||
setQueryTabDraft('tab-3', 'select * from draft_only;');
|
||||
|
||||
const targets = await collectApplicationQuitUnsavedSQLTargets([savedTab, unnamedTab], [savedQuery], vi.fn());
|
||||
|
||||
expect(targets).toHaveLength(1);
|
||||
expect(targets[0]).toMatchObject({
|
||||
kind: 'saved-query',
|
||||
tabId: 'tab-2',
|
||||
title: 'Saved query',
|
||||
draft: 'select 2;',
|
||||
connectionId: 'conn-1',
|
||||
dbName: 'main',
|
||||
});
|
||||
});
|
||||
|
||||
it('saves external SQL files and existing saved queries before application quit', async () => {
|
||||
const saveQuery = vi.fn(async (query: SavedQuery) => query);
|
||||
const writeSQLFile = vi.fn(async () => ({ success: true }));
|
||||
|
||||
await saveApplicationQuitUnsavedSQLTargets([
|
||||
{
|
||||
kind: 'sql-file',
|
||||
tabId: 'tab-1',
|
||||
title: 'file.sql',
|
||||
filePath: '/tmp/file.sql',
|
||||
draft: 'select 1;',
|
||||
},
|
||||
{
|
||||
kind: 'saved-query',
|
||||
tabId: 'tab-2',
|
||||
title: 'Saved query',
|
||||
savedQuery: createSavedQuery(),
|
||||
draft: 'select 2;',
|
||||
connectionId: 'conn-2',
|
||||
dbName: 'reporting',
|
||||
},
|
||||
], saveQuery, writeSQLFile);
|
||||
|
||||
expect(writeSQLFile).toHaveBeenCalledWith('/tmp/file.sql', 'select 1;');
|
||||
expect(saveQuery).toHaveBeenCalledWith(expect.objectContaining({
|
||||
id: 'saved-1',
|
||||
sql: 'select 2;',
|
||||
connectionId: 'conn-2',
|
||||
dbName: 'reporting',
|
||||
}));
|
||||
});
|
||||
});
|
||||
147
frontend/src/utils/sqlEditorApplicationQuit.ts
Normal file
147
frontend/src/utils/sqlEditorApplicationQuit.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import type { SavedQuery, TabData } from '../types';
|
||||
import { ReadSQLFile, WriteSQLFile } from '../../wailsjs/go/app/App';
|
||||
import {
|
||||
getSQLFileTabPath,
|
||||
hasSQLFileTabUnsavedChanges,
|
||||
isSQLFileMissingReadResult,
|
||||
isSQLFileQueryTab,
|
||||
normalizeSQLFileReadContent,
|
||||
} from './sqlFileTabDirty';
|
||||
import { getQueryTabDraft, getSQLFileTabDraft } from './sqlFileTabDrafts';
|
||||
|
||||
type QueryResultLike = {
|
||||
success?: boolean;
|
||||
message?: string;
|
||||
data?: unknown;
|
||||
};
|
||||
|
||||
export type ApplicationQuitUnsavedSQLTarget =
|
||||
| {
|
||||
kind: 'sql-file';
|
||||
tabId: string;
|
||||
title: string;
|
||||
filePath: string;
|
||||
draft: string;
|
||||
}
|
||||
| {
|
||||
kind: 'saved-query';
|
||||
tabId: string;
|
||||
title: string;
|
||||
savedQuery: SavedQuery;
|
||||
draft: string;
|
||||
connectionId: string;
|
||||
dbName: string;
|
||||
};
|
||||
|
||||
export type ReadSQLFileForQuit = (filePath: string) => Promise<QueryResultLike>;
|
||||
export type WriteSQLFileForQuit = (filePath: string, content: string) => Promise<QueryResultLike>;
|
||||
export type SaveQueryForQuit = (query: SavedQuery) => Promise<SavedQuery>;
|
||||
|
||||
const toTrimmedString = (value: unknown): string => String(value ?? '').trim();
|
||||
|
||||
const resolveTabTitle = (tab: TabData, fallback: string): string =>
|
||||
toTrimmedString(tab.title) || fallback;
|
||||
|
||||
const resolveSavedQueryForTab = (
|
||||
tab: TabData,
|
||||
savedQueries: SavedQuery[],
|
||||
): SavedQuery | null => {
|
||||
if (tab.type !== 'query' || isSQLFileQueryTab(tab)) return null;
|
||||
const savedId = toTrimmedString(tab.savedQueryId) || toTrimmedString(tab.id);
|
||||
if (!savedId) return null;
|
||||
return savedQueries.find((query) => query.id === savedId) || null;
|
||||
};
|
||||
|
||||
const hasSavedQueryUnsavedChanges = (
|
||||
tab: TabData,
|
||||
savedQuery: SavedQuery,
|
||||
draft: string,
|
||||
): boolean => {
|
||||
const connectionId = toTrimmedString(tab.connectionId || savedQuery.connectionId);
|
||||
const dbName = toTrimmedString(tab.dbName || savedQuery.dbName);
|
||||
return draft !== String(savedQuery.sql ?? '')
|
||||
|| connectionId !== toTrimmedString(savedQuery.connectionId)
|
||||
|| dbName !== toTrimmedString(savedQuery.dbName);
|
||||
};
|
||||
|
||||
export const buildApplicationQuitUnsavedSQLLabel = (
|
||||
targets: ApplicationQuitUnsavedSQLTarget[],
|
||||
): string => {
|
||||
if (targets.length === 0) return '';
|
||||
if (targets.length === 1) return targets[0].title;
|
||||
return String(targets.length);
|
||||
};
|
||||
|
||||
export const collectApplicationQuitUnsavedSQLTargets = async (
|
||||
tabs: TabData[],
|
||||
savedQueries: SavedQuery[],
|
||||
readSQLFile: ReadSQLFileForQuit = ReadSQLFile,
|
||||
): Promise<ApplicationQuitUnsavedSQLTarget[]> => {
|
||||
const targets: ApplicationQuitUnsavedSQLTarget[] = [];
|
||||
|
||||
for (const tab of tabs) {
|
||||
if (tab.type !== 'query') continue;
|
||||
|
||||
if (isSQLFileQueryTab(tab)) {
|
||||
const filePath = getSQLFileTabPath(tab);
|
||||
const draft = getSQLFileTabDraft(tab.id, String(tab.query ?? ''));
|
||||
const title = resolveTabTitle(tab, filePath);
|
||||
try {
|
||||
const res = await readSQLFile(filePath);
|
||||
if (res?.success) {
|
||||
if (hasSQLFileTabUnsavedChanges({ ...tab, query: draft }, normalizeSQLFileReadContent(res.data))) {
|
||||
targets.push({ kind: 'sql-file', tabId: tab.id, title, filePath, draft });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (isSQLFileMissingReadResult(res)) {
|
||||
targets.push({ kind: 'sql-file', tabId: tab.id, title, filePath, draft });
|
||||
continue;
|
||||
}
|
||||
throw new Error(res?.message || filePath);
|
||||
} catch {
|
||||
targets.push({ kind: 'sql-file', tabId: tab.id, title, filePath, draft });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const savedQuery = resolveSavedQueryForTab(tab, savedQueries);
|
||||
if (!savedQuery) continue;
|
||||
const draft = getQueryTabDraft(tab.id, String(tab.query ?? ''));
|
||||
if (!hasSavedQueryUnsavedChanges(tab, savedQuery, draft)) continue;
|
||||
targets.push({
|
||||
kind: 'saved-query',
|
||||
tabId: tab.id,
|
||||
title: resolveTabTitle(tab, savedQuery.name),
|
||||
savedQuery,
|
||||
draft,
|
||||
connectionId: toTrimmedString(tab.connectionId || savedQuery.connectionId),
|
||||
dbName: toTrimmedString(tab.dbName || savedQuery.dbName),
|
||||
});
|
||||
}
|
||||
|
||||
return targets;
|
||||
};
|
||||
|
||||
export const saveApplicationQuitUnsavedSQLTargets = async (
|
||||
targets: ApplicationQuitUnsavedSQLTarget[],
|
||||
saveQuery: SaveQueryForQuit,
|
||||
writeSQLFile: WriteSQLFileForQuit = WriteSQLFile,
|
||||
): Promise<void> => {
|
||||
for (const target of targets) {
|
||||
if (target.kind === 'sql-file') {
|
||||
const res = await writeSQLFile(target.filePath, target.draft);
|
||||
if (!res?.success) {
|
||||
throw new Error(res?.message || target.filePath);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
await saveQuery({
|
||||
...target.savedQuery,
|
||||
sql: target.draft,
|
||||
connectionId: target.connectionId,
|
||||
dbName: target.dbName,
|
||||
});
|
||||
}
|
||||
};
|
||||
4
frontend/wailsjs/go/app/App.d.ts
vendored
4
frontend/wailsjs/go/app/App.d.ts
vendored
@@ -12,6 +12,8 @@ export function ApplyDataRootDirectory(arg1:string,arg2:boolean):Promise<connect
|
||||
|
||||
export function CancelQuery(arg1:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function CancelApplicationQuit():Promise<connection.QueryResult>;
|
||||
|
||||
export function CancelSQLFileExecution(arg1:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function CheckDriverNetworkStatus():Promise<connection.QueryResult>;
|
||||
@@ -136,6 +138,8 @@ export function ExportTablesSQL(arg1:connection.ConnectionConfig,arg2:string,arg
|
||||
|
||||
export function ExportTablesSQLWithOptions(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<string>,arg4:boolean,arg5:boolean,arg6:app.ExportFileOptions):Promise<connection.QueryResult>;
|
||||
|
||||
export function ForceQuitApplication():Promise<connection.QueryResult>;
|
||||
|
||||
export function GenerateQueryID():Promise<string>;
|
||||
|
||||
export function GetAppInfo():Promise<connection.QueryResult>;
|
||||
|
||||
@@ -14,6 +14,10 @@ export function CancelQuery(arg1) {
|
||||
return window['go']['app']['App']['CancelQuery'](arg1);
|
||||
}
|
||||
|
||||
export function CancelApplicationQuit() {
|
||||
return window['go']['app']['App']['CancelApplicationQuit']();
|
||||
}
|
||||
|
||||
export function CancelSQLFileExecution(arg1) {
|
||||
return window['go']['app']['App']['CancelSQLFileExecution'](arg1);
|
||||
}
|
||||
@@ -262,6 +266,10 @@ export function ExportTablesSQLWithOptions(arg1, arg2, arg3, arg4, arg5, arg6) {
|
||||
return window['go']['app']['App']['ExportTablesSQLWithOptions'](arg1, arg2, arg3, arg4, arg5, arg6);
|
||||
}
|
||||
|
||||
export function ForceQuitApplication() {
|
||||
return window['go']['app']['App']['ForceQuitApplication']();
|
||||
}
|
||||
|
||||
export function GenerateQueryID() {
|
||||
return window['go']['app']['App']['GenerateQueryID']();
|
||||
}
|
||||
|
||||
@@ -79,26 +79,29 @@ type managedSQLTransaction struct {
|
||||
|
||||
// App struct
|
||||
type App struct {
|
||||
ctx context.Context
|
||||
startedAt time.Time
|
||||
dbCache map[string]cachedDatabase // Cache for DB connections
|
||||
connectFailures map[string]cachedConnectFailure
|
||||
mu sync.RWMutex // Mutex for cache access
|
||||
updateMu sync.Mutex
|
||||
updateState updateState
|
||||
i18nMu sync.RWMutex
|
||||
localizer *i18n.Localizer
|
||||
queryMu sync.RWMutex
|
||||
configDir string
|
||||
secretStore secretstore.SecretStore
|
||||
runningQueries map[string]queryContext // queryID -> cancelFunc and start time
|
||||
sqlTransactionMu sync.Mutex
|
||||
sqlTransactions map[string]*managedSQLTransaction
|
||||
jvmPreviewTokenMu sync.Mutex
|
||||
jvmPreviewTokens map[string]jvmPreviewConfirmationToken
|
||||
jvmPreviewTokenTTL time.Duration
|
||||
keepAliveCancel context.CancelFunc
|
||||
keepAliveDone chan struct{}
|
||||
ctx context.Context
|
||||
startedAt time.Time
|
||||
dbCache map[string]cachedDatabase // Cache for DB connections
|
||||
connectFailures map[string]cachedConnectFailure
|
||||
mu sync.RWMutex // Mutex for cache access
|
||||
updateMu sync.Mutex
|
||||
updateState updateState
|
||||
i18nMu sync.RWMutex
|
||||
localizer *i18n.Localizer
|
||||
applicationQuitMu sync.Mutex
|
||||
allowApplicationQuit bool
|
||||
applicationQuitPromptInFlight bool
|
||||
queryMu sync.RWMutex
|
||||
configDir string
|
||||
secretStore secretstore.SecretStore
|
||||
runningQueries map[string]queryContext // queryID -> cancelFunc and start time
|
||||
sqlTransactionMu sync.Mutex
|
||||
sqlTransactions map[string]*managedSQLTransaction
|
||||
jvmPreviewTokenMu sync.Mutex
|
||||
jvmPreviewTokens map[string]jvmPreviewConfirmationToken
|
||||
jvmPreviewTokenTTL time.Duration
|
||||
keepAliveCancel context.CancelFunc
|
||||
keepAliveDone chan struct{}
|
||||
}
|
||||
|
||||
// NewApp creates a new App application struct
|
||||
|
||||
80
internal/app/application_quit.go
Normal file
80
internal/app/application_quit.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
const applicationBeforeCloseRequestEvent = "app:before-close-request"
|
||||
|
||||
var (
|
||||
emitApplicationBeforeCloseRequest = wailsRuntime.EventsEmit
|
||||
quitApplicationRuntime = wailsRuntime.Quit
|
||||
)
|
||||
|
||||
// NewBeforeCloseHandler exposes the Wails close guard without binding the
|
||||
// lifecycle callback itself as a frontend RPC method.
|
||||
func NewBeforeCloseHandler(a *App) func(context.Context) bool {
|
||||
return func(ctx context.Context) bool {
|
||||
if a == nil {
|
||||
return false
|
||||
}
|
||||
return a.beforeClose(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) beforeClose(ctx context.Context) bool {
|
||||
a.applicationQuitMu.Lock()
|
||||
if a.allowApplicationQuit {
|
||||
a.allowApplicationQuit = false
|
||||
a.applicationQuitPromptInFlight = false
|
||||
a.applicationQuitMu.Unlock()
|
||||
return false
|
||||
}
|
||||
if a.applicationQuitPromptInFlight {
|
||||
a.applicationQuitMu.Unlock()
|
||||
return true
|
||||
}
|
||||
a.applicationQuitPromptInFlight = true
|
||||
a.applicationQuitMu.Unlock()
|
||||
|
||||
emitCtx := ctx
|
||||
if emitCtx == nil {
|
||||
emitCtx = a.ctx
|
||||
}
|
||||
if emitCtx != nil {
|
||||
emitApplicationBeforeCloseRequest(emitCtx, applicationBeforeCloseRequestEvent)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// CancelApplicationQuit resets a previously intercepted close request after
|
||||
// the frontend dialog is cancelled or cannot complete a save.
|
||||
func (a *App) CancelApplicationQuit() connection.QueryResult {
|
||||
if a == nil {
|
||||
return connection.QueryResult{Success: false, Message: "application is not initialized"}
|
||||
}
|
||||
a.applicationQuitMu.Lock()
|
||||
a.applicationQuitPromptInFlight = false
|
||||
a.allowApplicationQuit = false
|
||||
a.applicationQuitMu.Unlock()
|
||||
return connection.QueryResult{Success: true}
|
||||
}
|
||||
|
||||
// ForceQuitApplication is called only after the frontend has confirmed that
|
||||
// discarding or saving pending SQL edits is acceptable.
|
||||
func (a *App) ForceQuitApplication() connection.QueryResult {
|
||||
if a == nil {
|
||||
return connection.QueryResult{Success: false, Message: "application is not initialized"}
|
||||
}
|
||||
a.applicationQuitMu.Lock()
|
||||
a.allowApplicationQuit = true
|
||||
a.applicationQuitPromptInFlight = false
|
||||
a.applicationQuitMu.Unlock()
|
||||
if a.ctx != nil {
|
||||
quitApplicationRuntime(a.ctx)
|
||||
}
|
||||
return connection.QueryResult{Success: true}
|
||||
}
|
||||
75
internal/app/application_quit_test.go
Normal file
75
internal/app/application_quit_test.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestApplicationBeforeCloseEmitsPromptOnceUntilCancelled(t *testing.T) {
|
||||
originalEmit := emitApplicationBeforeCloseRequest
|
||||
originalQuit := quitApplicationRuntime
|
||||
t.Cleanup(func() {
|
||||
emitApplicationBeforeCloseRequest = originalEmit
|
||||
quitApplicationRuntime = originalQuit
|
||||
})
|
||||
|
||||
var emitted []string
|
||||
emitApplicationBeforeCloseRequest = func(_ context.Context, eventName string, _ ...interface{}) {
|
||||
emitted = append(emitted, eventName)
|
||||
}
|
||||
quitApplicationRuntime = func(context.Context) {}
|
||||
|
||||
app := NewAppWithSecretStore(nil)
|
||||
handler := NewBeforeCloseHandler(app)
|
||||
|
||||
if prevent := handler(context.Background()); !prevent {
|
||||
t.Fatal("expected first close request to be prevented")
|
||||
}
|
||||
if len(emitted) != 1 || emitted[0] != applicationBeforeCloseRequestEvent {
|
||||
t.Fatalf("expected one before-close event, got %#v", emitted)
|
||||
}
|
||||
if prevent := handler(context.Background()); !prevent {
|
||||
t.Fatal("expected repeated close request to stay prevented while prompt is open")
|
||||
}
|
||||
if len(emitted) != 1 {
|
||||
t.Fatalf("expected no duplicate prompt event, got %#v", emitted)
|
||||
}
|
||||
|
||||
result := app.CancelApplicationQuit()
|
||||
if !result.Success {
|
||||
t.Fatalf("expected cancel quit success, got %#v", result)
|
||||
}
|
||||
if prevent := handler(context.Background()); !prevent {
|
||||
t.Fatal("expected close request after cancellation to be prevented again")
|
||||
}
|
||||
if len(emitted) != 2 {
|
||||
t.Fatalf("expected prompt event after cancellation, got %#v", emitted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForceQuitApplicationAllowsNextCloseRequest(t *testing.T) {
|
||||
originalEmit := emitApplicationBeforeCloseRequest
|
||||
originalQuit := quitApplicationRuntime
|
||||
t.Cleanup(func() {
|
||||
emitApplicationBeforeCloseRequest = originalEmit
|
||||
quitApplicationRuntime = originalQuit
|
||||
})
|
||||
|
||||
emitApplicationBeforeCloseRequest = func(context.Context, string, ...interface{}) {}
|
||||
quitCalls := 0
|
||||
quitApplicationRuntime = func(context.Context) {
|
||||
quitCalls++
|
||||
}
|
||||
|
||||
app := NewAppWithSecretStore(nil)
|
||||
app.ctx = context.Background()
|
||||
if result := app.ForceQuitApplication(); !result.Success {
|
||||
t.Fatalf("expected force quit success, got %#v", result)
|
||||
}
|
||||
if quitCalls != 1 {
|
||||
t.Fatalf("expected runtime Quit to be called once, got %d", quitCalls)
|
||||
}
|
||||
if prevent := NewBeforeCloseHandler(app)(context.Background()); prevent {
|
||||
t.Fatal("expected next close request to be allowed after force quit")
|
||||
}
|
||||
}
|
||||
1
main.go
1
main.go
@@ -61,6 +61,7 @@ func main() {
|
||||
aiService.Shutdown()
|
||||
application.Shutdown()
|
||||
},
|
||||
OnBeforeClose: app.NewBeforeCloseHandler(application),
|
||||
Bind: []interface{}{
|
||||
application,
|
||||
aiService,
|
||||
|
||||
@@ -2560,6 +2560,16 @@
|
||||
"app.proxy.title": "Globale Proxy-Einstellungen",
|
||||
"app.proxy.type": "Proxy-Typ",
|
||||
"app.proxy.username_optional": "Benutzername (optional)",
|
||||
"app.quit.message.quit_failed": "Anwendung konnte nicht beendet werden: {{detail}}",
|
||||
"app.quit.unsaved_sql.cancel": "Abbrechen",
|
||||
"app.quit.unsaved_sql.confirm_exit": "Trotzdem beenden",
|
||||
"app.quit.unsaved_sql.content_multiple": "{{label}} SQL-Editoren enthalten ungespeicherte Änderungen. Speichern Sie vor dem Beenden oder beenden Sie und verwerfen die ungespeicherten Änderungen.",
|
||||
"app.quit.unsaved_sql.content_single": "{{label}} enthält ungespeichertes SQL. Speichern Sie vor dem Beenden oder beenden Sie und verwerfen die ungespeicherten Änderungen.",
|
||||
"app.quit.unsaved_sql.inspect_failed": "Ungespeichertes SQL konnte nicht geprüft werden. Beenden wurde abgebrochen: {{detail}}",
|
||||
"app.quit.unsaved_sql.save_exit": "Speichern und beenden",
|
||||
"app.quit.unsaved_sql.save_failed_cancel_exit": "Speichern fehlgeschlagen. Beenden wurde abgebrochen: {{detail}}",
|
||||
"app.quit.unsaved_sql.saved": "SQL-Änderungen gespeichert, Anwendung wird beendet",
|
||||
"app.quit.unsaved_sql.title": "Ungespeichertes SQL",
|
||||
"app.security_update.error.capability_unavailable": "Sicherheitsupdate-Funktion ist nicht verfügbar",
|
||||
"app.security_update.message.completed": "Gespeicherte Konfigurationen haben das Sicherheitsupdate abgeschlossen",
|
||||
"app.security_update.message.needs_attention": "Das Update ist noch nicht abgeschlossen. Einige Konfigurationen erfordern Aufmerksamkeit.",
|
||||
|
||||
@@ -2560,6 +2560,16 @@
|
||||
"app.proxy.title": "Global Proxy Settings",
|
||||
"app.proxy.type": "Proxy Type",
|
||||
"app.proxy.username_optional": "Username (optional)",
|
||||
"app.quit.message.quit_failed": "Failed to quit application: {{detail}}",
|
||||
"app.quit.unsaved_sql.cancel": "Cancel",
|
||||
"app.quit.unsaved_sql.confirm_exit": "Quit anyway",
|
||||
"app.quit.unsaved_sql.content_multiple": "{{label}} SQL editors have unsaved changes. Save before quitting, or quit and discard the unsaved changes.",
|
||||
"app.quit.unsaved_sql.content_single": "{{label}} has unsaved SQL. Save before quitting, or quit and discard the unsaved changes.",
|
||||
"app.quit.unsaved_sql.inspect_failed": "Failed to inspect unsaved SQL. Quit was cancelled: {{detail}}",
|
||||
"app.quit.unsaved_sql.save_exit": "Save and quit",
|
||||
"app.quit.unsaved_sql.save_failed_cancel_exit": "Save failed. Quit was cancelled: {{detail}}",
|
||||
"app.quit.unsaved_sql.saved": "SQL changes saved, quitting",
|
||||
"app.quit.unsaved_sql.title": "Unsaved SQL",
|
||||
"app.security_update.error.capability_unavailable": "Security update capability is unavailable",
|
||||
"app.security_update.message.completed": "Saved configurations have completed the security update",
|
||||
"app.security_update.message.needs_attention": "The update is not complete yet. A few configurations need your attention.",
|
||||
|
||||
@@ -2560,6 +2560,16 @@
|
||||
"app.proxy.title": "グローバルプロキシ設定",
|
||||
"app.proxy.type": "プロキシ種別",
|
||||
"app.proxy.username_optional": "ユーザー名 (任意)",
|
||||
"app.quit.message.quit_failed": "アプリケーションを終了できませんでした: {{detail}}",
|
||||
"app.quit.unsaved_sql.cancel": "キャンセル",
|
||||
"app.quit.unsaved_sql.confirm_exit": "終了する",
|
||||
"app.quit.unsaved_sql.content_multiple": "{{label}} 件の SQL エディターに未保存の変更があります。終了前に保存するか、未保存の変更を破棄して終了できます。",
|
||||
"app.quit.unsaved_sql.content_single": "{{label}} には未保存の SQL があります。終了前に保存するか、未保存の変更を破棄して終了できます。",
|
||||
"app.quit.unsaved_sql.inspect_failed": "未保存 SQL の確認に失敗したため、終了をキャンセルしました: {{detail}}",
|
||||
"app.quit.unsaved_sql.save_exit": "保存して終了",
|
||||
"app.quit.unsaved_sql.save_failed_cancel_exit": "保存に失敗したため、終了をキャンセルしました: {{detail}}",
|
||||
"app.quit.unsaved_sql.saved": "SQL の変更を保存しました。終了します",
|
||||
"app.quit.unsaved_sql.title": "未保存の SQL があります",
|
||||
"app.security_update.error.capability_unavailable": "安全更新機能は利用できません",
|
||||
"app.security_update.message.completed": "保存済み設定の安全更新が完了しました",
|
||||
"app.security_update.message.needs_attention": "更新はまだ完了していません。一部の設定に対応が必要です。",
|
||||
|
||||
@@ -2560,6 +2560,16 @@
|
||||
"app.proxy.title": "Настройки глобального прокси",
|
||||
"app.proxy.type": "Тип прокси",
|
||||
"app.proxy.username_optional": "Имя пользователя (необязательно)",
|
||||
"app.quit.message.quit_failed": "Не удалось выйти из приложения: {{detail}}",
|
||||
"app.quit.unsaved_sql.cancel": "Отмена",
|
||||
"app.quit.unsaved_sql.confirm_exit": "Выйти",
|
||||
"app.quit.unsaved_sql.content_multiple": "В {{label}} SQL-редакторах есть несохраненные изменения. Сохраните перед выходом или выйдите с потерей несохраненных изменений.",
|
||||
"app.quit.unsaved_sql.content_single": "В {{label}} есть несохраненный SQL. Сохраните перед выходом или выйдите с потерей несохраненных изменений.",
|
||||
"app.quit.unsaved_sql.inspect_failed": "Не удалось проверить несохраненный SQL. Выход отменен: {{detail}}",
|
||||
"app.quit.unsaved_sql.save_exit": "Сохранить и выйти",
|
||||
"app.quit.unsaved_sql.save_failed_cancel_exit": "Сохранить не удалось. Выход отменен: {{detail}}",
|
||||
"app.quit.unsaved_sql.saved": "Изменения SQL сохранены, выполняется выход",
|
||||
"app.quit.unsaved_sql.title": "Есть несохраненный SQL",
|
||||
"app.security_update.error.capability_unavailable": "Функция безопасного обновления недоступна",
|
||||
"app.security_update.message.completed": "Безопасное обновление сохраненных конфигураций завершено",
|
||||
"app.security_update.message.needs_attention": "Обновление еще не завершено. Несколько конфигураций требуют внимания.",
|
||||
|
||||
@@ -2560,6 +2560,16 @@
|
||||
"app.proxy.title": "全局代理设置",
|
||||
"app.proxy.type": "代理类型",
|
||||
"app.proxy.username_optional": "用户名(可选)",
|
||||
"app.quit.message.quit_failed": "退出应用失败:{{detail}}",
|
||||
"app.quit.unsaved_sql.cancel": "取消",
|
||||
"app.quit.unsaved_sql.confirm_exit": "确认退出",
|
||||
"app.quit.unsaved_sql.content_multiple": "当前有 {{label}} 个 SQL 编辑器存在未保存修改。退出前可以保存,或确认退出并丢弃未保存修改。",
|
||||
"app.quit.unsaved_sql.content_single": "{{label}} 存在未保存 SQL。退出前可以保存,或确认退出并丢弃未保存修改。",
|
||||
"app.quit.unsaved_sql.inspect_failed": "检查未保存 SQL 失败,已取消退出:{{detail}}",
|
||||
"app.quit.unsaved_sql.save_exit": "保存退出",
|
||||
"app.quit.unsaved_sql.save_failed_cancel_exit": "保存失败,已取消退出:{{detail}}",
|
||||
"app.quit.unsaved_sql.saved": "SQL 修改已保存,正在退出",
|
||||
"app.quit.unsaved_sql.title": "当前有未保存的 SQL",
|
||||
"app.security_update.error.capability_unavailable": "安全更新能力不可用",
|
||||
"app.security_update.message.completed": "已保存配置已完成安全更新",
|
||||
"app.security_update.message.needs_attention": "更新尚未完成,有少量配置需要你处理",
|
||||
|
||||
@@ -2560,6 +2560,16 @@
|
||||
"app.proxy.title": "全局代理設定",
|
||||
"app.proxy.type": "代理类型",
|
||||
"app.proxy.username_optional": "使用者名(可选)",
|
||||
"app.quit.message.quit_failed": "退出應用程式失敗:{{detail}}",
|
||||
"app.quit.unsaved_sql.cancel": "取消",
|
||||
"app.quit.unsaved_sql.confirm_exit": "確認退出",
|
||||
"app.quit.unsaved_sql.content_multiple": "目前有 {{label}} 個 SQL 編輯器存在未儲存修改。退出前可以儲存,或確認退出並丟棄未儲存修改。",
|
||||
"app.quit.unsaved_sql.content_single": "{{label}} 存在未儲存 SQL。退出前可以儲存,或確認退出並丟棄未儲存修改。",
|
||||
"app.quit.unsaved_sql.inspect_failed": "檢查未儲存 SQL 失敗,已取消退出:{{detail}}",
|
||||
"app.quit.unsaved_sql.save_exit": "儲存退出",
|
||||
"app.quit.unsaved_sql.save_failed_cancel_exit": "儲存失敗,已取消退出:{{detail}}",
|
||||
"app.quit.unsaved_sql.saved": "SQL 修改已儲存,正在退出",
|
||||
"app.quit.unsaved_sql.title": "目前有未儲存的 SQL",
|
||||
"app.security_update.error.capability_unavailable": "安全更新能力無法使用",
|
||||
"app.security_update.message.completed": "已儲存設定已完成安全更新",
|
||||
"app.security_update.message.needs_attention": "更新尚未完成,有少量設定需要你處理",
|
||||
|
||||
Reference in New Issue
Block a user