mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-12 16:02:48 +08:00
✨ feat(sql-editor): 关闭应用时提示保存未保存SQL
- 关闭应用前拦截 Wails 退出事件,前端弹出确认退出、保存退出和取消三按钮\n- 保存退出支持外部 SQL 文件和已保存查询,未命名临时查询保留草稿恢复语义\n- 补充退出保护后端测试、前端保存目标测试和多语言文案
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user