fix(tui): enable Ctrl+C/ESC to cancel running backup/restore operations

PROBLEM: Users could not interrupt backup or restore operations through
the TUI interface. Pressing Ctrl+C or ESC did nothing during execution.

ROOT CAUSE:
- BackupExecutionModel ignored ALL key presses while running (only handled when done)
- RestoreExecutionModel returned tea.Quit but didn't cancel the context
- The operation goroutine kept running in the background with its own context

FIX:
- Added cancel context.CancelFunc to both execution models
- Create child context with WithCancel in New*Execution constructors
- Handle ctrl+c and esc during execution to call cancel()
- Show 'Cancelling...' status while waiting for graceful shutdown
- Show cancel hint in View: 'Press Ctrl+C or ESC to cancel'

The fix works because:
- exec.CommandContext(ctx) will SIGKILL the subprocess when ctx is cancelled
- pg_dump, pg_restore, psql, mysql all get terminated properly
- User sees immediate feedback that cancellation is in progress
This commit is contained in:
2026-01-07 09:53:47 +01:00
parent 9ad925191e
commit 9f375621d1
2 changed files with 63 additions and 15 deletions

View File

@@ -20,12 +20,14 @@ type BackupExecutionModel struct {
logger logger.Logger
parent tea.Model
ctx context.Context
cancel context.CancelFunc // Cancel function to stop the operation
backupType string
databaseName string
ratio int
status string
progress int
done bool
cancelling bool // True when user has requested cancellation
err error
result string
startTime time.Time
@@ -34,11 +36,14 @@ type BackupExecutionModel struct {
}
func NewBackupExecution(cfg *config.Config, log logger.Logger, parent tea.Model, ctx context.Context, backupType, dbName string, ratio int) BackupExecutionModel {
// Create a cancellable context derived from parent
childCtx, cancel := context.WithCancel(ctx)
return BackupExecutionModel{
config: cfg,
logger: log,
parent: parent,
ctx: ctx,
ctx: childCtx,
cancel: cancel,
backupType: backupType,
databaseName: dbName,
ratio: ratio,
@@ -206,9 +211,21 @@ func (m BackupExecutionModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
case tea.KeyMsg:
if m.done {
switch msg.String() {
case "enter", "esc", "q":
switch msg.String() {
case "ctrl+c", "esc":
if !m.done && !m.cancelling {
// User requested cancellation - cancel the context
m.cancelling = true
m.status = "⏹️ Cancelling backup... (please wait)"
if m.cancel != nil {
m.cancel()
}
return m, nil
} else if m.done {
return m.parent, nil
}
case "enter", "q":
if m.done {
return m.parent, nil
}
}
@@ -240,7 +257,12 @@ func (m BackupExecutionModel) View() string {
// Status with spinner
if !m.done {
s.WriteString(fmt.Sprintf(" %s %s\n", spinnerFrames[m.spinnerFrame], m.status))
if m.cancelling {
s.WriteString(fmt.Sprintf(" %s %s\n", spinnerFrames[m.spinnerFrame], m.status))
} else {
s.WriteString(fmt.Sprintf(" %s %s\n", spinnerFrames[m.spinnerFrame], m.status))
s.WriteString("\n ⌨️ Press Ctrl+C or ESC to cancel\n")
}
} else {
s.WriteString(fmt.Sprintf(" %s\n\n", m.status))

View File

@@ -24,6 +24,7 @@ type RestoreExecutionModel struct {
logger logger.Logger
parent tea.Model
ctx context.Context
cancel context.CancelFunc // Cancel function to stop the operation
archive ArchiveInfo
targetDB string
cleanFirst bool
@@ -44,19 +45,23 @@ type RestoreExecutionModel struct {
spinnerFrames []string
// Results
done bool
err error
result string
elapsed time.Duration
done bool
cancelling bool // True when user has requested cancellation
err error
result string
elapsed time.Duration
}
// NewRestoreExecution creates a new restore execution model
func NewRestoreExecution(cfg *config.Config, log logger.Logger, parent tea.Model, ctx context.Context, archive ArchiveInfo, targetDB string, cleanFirst, createIfMissing bool, restoreType string, cleanClusterFirst bool, existingDBs []string, saveDebugLog bool, workDir string) RestoreExecutionModel {
// Create a cancellable context derived from parent
childCtx, cancel := context.WithCancel(ctx)
return RestoreExecutionModel{
config: cfg,
logger: log,
parent: parent,
ctx: ctx,
ctx: childCtx,
cancel: cancel,
archive: archive,
targetDB: targetDB,
cleanFirst: cleanFirst,
@@ -274,11 +279,32 @@ func (m RestoreExecutionModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "q":
// Always allow quitting
return m.parent, tea.Quit
case "enter", " ", "esc":
case "ctrl+c", "esc":
if !m.done && !m.cancelling {
// User requested cancellation - cancel the context
m.cancelling = true
m.status = "⏹️ Cancelling restore... (please wait)"
m.phase = "Cancelling"
if m.cancel != nil {
m.cancel()
}
return m, nil
} else if m.done {
return m.parent, nil
}
case "q":
if !m.done && !m.cancelling {
m.cancelling = true
m.status = "⏹️ Cancelling restore... (please wait)"
m.phase = "Cancelling"
if m.cancel != nil {
m.cancel()
}
return m, nil
} else if m.done {
return m.parent, tea.Quit
}
case "enter", " ":
if m.done {
return m.parent, nil
}