diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 71b22a4..1bd4650 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -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 }) => (
+ <>
+
+
+
+ >
+ ),
+ 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(); }}
/>
)}
diff --git a/frontend/src/utils/sqlEditorApplicationQuit.test.ts b/frontend/src/utils/sqlEditorApplicationQuit.test.ts
new file mode 100644
index 0000000..7f7eca0
--- /dev/null
+++ b/frontend/src/utils/sqlEditorApplicationQuit.test.ts
@@ -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 => ({
+ id: 'tab-1',
+ title: 'Query',
+ type: 'query',
+ connectionId: 'conn-1',
+ dbName: 'main',
+ query: '',
+ ...overrides,
+});
+
+const createSavedQuery = (overrides: Partial = {}): 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',
+ }));
+ });
+});
diff --git a/frontend/src/utils/sqlEditorApplicationQuit.ts b/frontend/src/utils/sqlEditorApplicationQuit.ts
new file mode 100644
index 0000000..12540c8
--- /dev/null
+++ b/frontend/src/utils/sqlEditorApplicationQuit.ts
@@ -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;
+export type WriteSQLFileForQuit = (filePath: string, content: string) => Promise;
+export type SaveQueryForQuit = (query: SavedQuery) => Promise;
+
+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 => {
+ 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 => {
+ 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,
+ });
+ }
+};
diff --git a/frontend/wailsjs/go/app/App.d.ts b/frontend/wailsjs/go/app/App.d.ts
index cfe1a18..8b8527d 100755
--- a/frontend/wailsjs/go/app/App.d.ts
+++ b/frontend/wailsjs/go/app/App.d.ts
@@ -12,6 +12,8 @@ export function ApplyDataRootDirectory(arg1:string,arg2:boolean):Promise;
+export function CancelApplicationQuit():Promise;
+
export function CancelSQLFileExecution(arg1:string):Promise;
export function CheckDriverNetworkStatus():Promise;
@@ -136,6 +138,8 @@ export function ExportTablesSQL(arg1:connection.ConnectionConfig,arg2:string,arg
export function ExportTablesSQLWithOptions(arg1:connection.ConnectionConfig,arg2:string,arg3:Array,arg4:boolean,arg5:boolean,arg6:app.ExportFileOptions):Promise;
+export function ForceQuitApplication():Promise;
+
export function GenerateQueryID():Promise;
export function GetAppInfo():Promise;
diff --git a/frontend/wailsjs/go/app/App.js b/frontend/wailsjs/go/app/App.js
index 631f3e1..c1a9df7 100755
--- a/frontend/wailsjs/go/app/App.js
+++ b/frontend/wailsjs/go/app/App.js
@@ -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']();
}
diff --git a/internal/app/app.go b/internal/app/app.go
index 223d3b2..414e3e8 100644
--- a/internal/app/app.go
+++ b/internal/app/app.go
@@ -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
diff --git a/internal/app/application_quit.go b/internal/app/application_quit.go
new file mode 100644
index 0000000..6067a21
--- /dev/null
+++ b/internal/app/application_quit.go
@@ -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}
+}
diff --git a/internal/app/application_quit_test.go b/internal/app/application_quit_test.go
new file mode 100644
index 0000000..350a246
--- /dev/null
+++ b/internal/app/application_quit_test.go
@@ -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")
+ }
+}
diff --git a/main.go b/main.go
index 6b2fac4..2ccb86c 100644
--- a/main.go
+++ b/main.go
@@ -61,6 +61,7 @@ func main() {
aiService.Shutdown()
application.Shutdown()
},
+ OnBeforeClose: app.NewBeforeCloseHandler(application),
Bind: []interface{}{
application,
aiService,
diff --git a/shared/i18n/de-DE.json b/shared/i18n/de-DE.json
index 00d111a..a71d7f6 100644
--- a/shared/i18n/de-DE.json
+++ b/shared/i18n/de-DE.json
@@ -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.",
diff --git a/shared/i18n/en-US.json b/shared/i18n/en-US.json
index 71ced4c..8b2d9eb 100644
--- a/shared/i18n/en-US.json
+++ b/shared/i18n/en-US.json
@@ -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.",
diff --git a/shared/i18n/ja-JP.json b/shared/i18n/ja-JP.json
index 4dc9f48..eed9e5e 100644
--- a/shared/i18n/ja-JP.json
+++ b/shared/i18n/ja-JP.json
@@ -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": "更新はまだ完了していません。一部の設定に対応が必要です。",
diff --git a/shared/i18n/ru-RU.json b/shared/i18n/ru-RU.json
index 031c95b..d203a60 100644
--- a/shared/i18n/ru-RU.json
+++ b/shared/i18n/ru-RU.json
@@ -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": "Обновление еще не завершено. Несколько конфигураций требуют внимания.",
diff --git a/shared/i18n/zh-CN.json b/shared/i18n/zh-CN.json
index 61c15c9..0d779c5 100644
--- a/shared/i18n/zh-CN.json
+++ b/shared/i18n/zh-CN.json
@@ -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": "更新尚未完成,有少量配置需要你处理",
diff --git a/shared/i18n/zh-TW.json b/shared/i18n/zh-TW.json
index 498258f..ef4183a 100644
--- a/shared/i18n/zh-TW.json
+++ b/shared/i18n/zh-TW.json
@@ -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": "更新尚未完成,有少量設定需要你處理",