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": "更新尚未完成,有少量設定需要你處理",