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 logger logger.Logger
parent tea.Model parent tea.Model
ctx context.Context ctx context.Context
cancel context.CancelFunc // Cancel function to stop the operation
backupType string backupType string
databaseName string databaseName string
ratio int ratio int
status string status string
progress int progress int
done bool done bool
cancelling bool // True when user has requested cancellation
err error err error
result string result string
startTime time.Time 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 { 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{ return BackupExecutionModel{
config: cfg, config: cfg,
logger: log, logger: log,
parent: parent, parent: parent,
ctx: ctx, ctx: childCtx,
cancel: cancel,
backupType: backupType, backupType: backupType,
databaseName: dbName, databaseName: dbName,
ratio: ratio, ratio: ratio,
@@ -206,9 +211,21 @@ func (m BackupExecutionModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
case tea.KeyMsg: case tea.KeyMsg:
if m.done {
switch msg.String() { switch msg.String() {
case "enter", "esc", "q": 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 return m.parent, nil
} }
} }
@@ -240,7 +257,12 @@ func (m BackupExecutionModel) View() string {
// Status with spinner // Status with spinner
if !m.done { if !m.done {
if m.cancelling {
s.WriteString(fmt.Sprintf(" %s %s\n", spinnerFrames[m.spinnerFrame], m.status)) 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 { } else {
s.WriteString(fmt.Sprintf(" %s\n\n", m.status)) s.WriteString(fmt.Sprintf(" %s\n\n", m.status))

View File

@@ -24,6 +24,7 @@ type RestoreExecutionModel struct {
logger logger.Logger logger logger.Logger
parent tea.Model parent tea.Model
ctx context.Context ctx context.Context
cancel context.CancelFunc // Cancel function to stop the operation
archive ArchiveInfo archive ArchiveInfo
targetDB string targetDB string
cleanFirst bool cleanFirst bool
@@ -45,6 +46,7 @@ type RestoreExecutionModel struct {
// Results // Results
done bool done bool
cancelling bool // True when user has requested cancellation
err error err error
result string result string
elapsed time.Duration elapsed time.Duration
@@ -52,11 +54,14 @@ type RestoreExecutionModel struct {
// NewRestoreExecution creates a new restore execution model // 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 { 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{ return RestoreExecutionModel{
config: cfg, config: cfg,
logger: log, logger: log,
parent: parent, parent: parent,
ctx: ctx, ctx: childCtx,
cancel: cancel,
archive: archive, archive: archive,
targetDB: targetDB, targetDB: targetDB,
cleanFirst: cleanFirst, cleanFirst: cleanFirst,
@@ -274,11 +279,32 @@ func (m RestoreExecutionModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyMsg: case tea.KeyMsg:
switch msg.String() { switch msg.String() {
case "ctrl+c", "q": case "ctrl+c", "esc":
// Always allow quitting 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 return m.parent, tea.Quit
}
case "enter", " ", "esc": case "enter", " ":
if m.done { if m.done {
return m.parent, nil return m.parent, nil
} }