From e1f8f605fe816fca4aacc212f8f41736e08b3411 Mon Sep 17 00:00:00 2001 From: Balaji J Date: Thu, 21 May 2026 06:32:28 +0530 Subject: [PATCH 1/6] add query cancallation Signed-off-by: Balaji J --- internal/database/client.go | 9 ++++++++- internal/database/executor.go | 18 ++++++++++++++++-- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/internal/database/client.go b/internal/database/client.go index 1cf74f0..46810f5 100644 --- a/internal/database/client.go +++ b/internal/database/client.go @@ -9,8 +9,8 @@ import ( "strings" "time" - "github.com/balajz/pgxcli/internal/database/result" "github.com/balaji01-4d/pgxspecial" + "github.com/balajz/pgxcli/internal/database/result" ) const nilPlaceholder = "(nil)" @@ -103,6 +103,13 @@ func (c *Client) ChangeDatabase(ctx context.Context, dbName string) error { return nil } +func (c *Client) Cancel(ctx context.Context) error { + if !c.IsConnected() { + return ErrConnectionNotEstablished + } + return c.executor.cancel(ctx) +} + // ParsePrompt resolves prompt placeholders using current connection metadata. func (c *Client) ParsePrompt(str string) string { var user, host, shortHost, db, port string diff --git a/internal/database/executor.go b/internal/database/executor.go index dc6bde1..cae5ecf 100644 --- a/internal/database/executor.go +++ b/internal/database/executor.go @@ -2,22 +2,29 @@ package database import ( "context" + "errors" "fmt" "log/slog" "time" - "github.com/balajz/pgxcli/internal/database/result" "github.com/balaji01-4d/pgxspecial" + "github.com/balajz/pgxcli/internal/database/result" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" ) +var ( + ErrConnectionClosed = errors.New("connection closed unexpectedly") + ErrConnectionNotEstablished = errors.New("connection not established") +) + type conn interface { Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) QueryRow(ctx context.Context, sql string, args ...any) pgx.Row Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) Config() *pgx.ConnConfig + PgConn() *pgconn.PgConn Ping(ctx context.Context) error Close(ctx context.Context) error } @@ -108,6 +115,13 @@ func (e *executor) executeSpecial(ctx context.Context, cmd string) (pgxspecial.S return normalizedRows, ok, nil } +func (e *executor) cancel(ctx context.Context) error { + if e.Conn == nil { + return ErrConnectionNotEstablished + } + return e.Conn.PgConn().CancelRequest(ctx) +} + func (e *executor) close(ctx context.Context) error { if e.Conn != nil { return e.Conn.Close(ctx) @@ -117,7 +131,7 @@ func (e *executor) close(ctx context.Context) error { func (e *executor) ping(ctx context.Context) error { if e.Conn == nil { - return fmt.Errorf("database not connected") + return ErrConnectionNotEstablished } return e.Conn.Ping(ctx) } From 8e6c308d8a72cc84b839c0838a0006814b812390 Mon Sep 17 00:00:00 2001 From: Balaji J Date: Thu, 21 May 2026 13:54:11 +0530 Subject: [PATCH 2/6] feat: add client field in pgxcli app strcut Signed-off-by: Balaji J --- internal/app/app.go | 28 ++++++++++++-------- internal/app/renderer/table_renderer.go | 2 +- internal/app/renderer/table_renderer_test.go | 2 +- internal/app/ui/model.go | 2 ++ internal/cli/root.go | 4 +-- 5 files changed, 23 insertions(+), 15 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index 28f3b8c..f40a69b 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -13,6 +13,7 @@ import ( "time" tea "charm.land/bubbletea/v2" + "github.com/balaji01-4d/pgxspecial" "github.com/balajz/pgxcli/internal/app/commands" "github.com/balajz/pgxcli/internal/app/renderer" "github.com/balajz/pgxcli/internal/app/ui" @@ -22,13 +23,12 @@ import ( "github.com/balajz/pgxcli/internal/database" "github.com/balajz/pgxcli/internal/database/result" "github.com/balajz/pgxcli/internal/parser" - "github.com/balaji01-4d/pgxspecial" ) // Application defines the interface for the main application logic. type Application interface { // Start starts the main repl loop, reading input, executing commands and printing results until the user exits. - Start(ctx context.Context, client *database.Client) error + Start(ctx context.Context) error // Close performs saving history before exiting. Close() error @@ -46,20 +46,22 @@ type pgxCLI struct { config *config.Config logger *slog.Logger completer *completer.Completer + client *database.Client } -func New(cfg *config.Config, printer cliio.Printer, logger *slog.Logger, completer *completer.Completer) (Application, error) { +func New(cfg *config.Config, printer cliio.Printer, logger *slog.Logger, completer *completer.Completer, client *database.Client) (Application, error) { return &pgxCLI{ config: cfg, logger: logger, Printer: printer, completer: completer, + client: client, }, nil } -func (p *pgxCLI) execute(ctx context.Context, client *database.Client, query string) tea.Cmd { +func (p *pgxCLI) execute(ctx context.Context, query string) tea.Cmd { promptReady := func() tea.Msg { - prefix := client.ParsePrompt(p.config.Main.Prompt) + prefix := p.client.ParsePrompt(p.config.Main.Prompt) return ui.ReadyMsg{Prefix: prefix} // this is used to unblock input after executing a command } @@ -72,7 +74,7 @@ func (p *pgxCLI) execute(ctx context.Context, client *database.Client, query str } return func() tea.Msg { - metaResult, okay, err := client.ExecuteSpecial(ctx, query) + metaResult, okay, err := p.client.ExecuteSpecial(ctx, query) if err != nil { p.logger.Error("error executing special command", "error", err) return ui.ExecCmdMsg{Cmd: tea.Sequence(p.printError(err), promptReady)} @@ -80,7 +82,7 @@ func (p *pgxCLI) execute(ctx context.Context, client *database.Client, query str if okay { start := time.Now() p.logger.Debug("special command executed", "result_kind", metaResult.ResultKind()) - result, quit, err := p.handleSpecialCommand(ctx, metaResult, client) + result, quit, err := p.handleSpecialCommand(ctx, metaResult, p.client) if quit { p.logger.Info("REPL exiting via quit command") return ui.ExecCmdMsg{Cmd: tea.Quit} @@ -110,7 +112,7 @@ func (p *pgxCLI) execute(ctx context.Context, client *database.Client, query str continue } - queryResult, err := client.ExecuteQuery(ctx, stmt) + queryResult, err := p.client.ExecuteQuery(ctx, stmt) if err != nil { p.logger.Error("query execution failed", "error", err) cmds = append(cmds, p.printError(err)) @@ -136,12 +138,12 @@ func (p *pgxCLI) execute(ctx context.Context, client *database.Client, query str } } -func (p *pgxCLI) Start(ctx context.Context, client *database.Client) error { +func (p *pgxCLI) Start(ctx context.Context) error { executeFunc := func(query string) tea.Cmd { - return p.execute(ctx, client, query) + return p.execute(ctx, query) } - initialPrefix := client.ParsePrompt(p.config.Main.Prompt) + initialPrefix := p.client.ParsePrompt(p.config.Main.Prompt) m, err := ui.New(initialPrefix, p.completer.GetKeyWords(), p.config.Main.HistoryFile, string(p.config.Main.Style), executeFunc) if err != nil { return fmt.Errorf("creating UI model: %w", err) @@ -223,6 +225,10 @@ func (p *pgxCLI) handleSpecialCommand(ctx context.Context, metaResult pgxspecial } } +func (p *pgxCLI) cancel() error { + return nil +} + func (p *pgxCLI) handleQueryResult(r result.Result) (tea.Cmd, error) { res, ok := r.(*result.QueryResult) if !ok { diff --git a/internal/app/renderer/table_renderer.go b/internal/app/renderer/table_renderer.go index 8827f69..5cd8905 100644 --- a/internal/app/renderer/table_renderer.go +++ b/internal/app/renderer/table_renderer.go @@ -4,8 +4,8 @@ import ( "fmt" "strings" - "github.com/balajz/pgxcli/internal/config" "github.com/balaji01-4d/pgxspecial" + "github.com/balajz/pgxcli/internal/config" ) type rowsTableResult interface { diff --git a/internal/app/renderer/table_renderer_test.go b/internal/app/renderer/table_renderer_test.go index 2aa4bc1..da1f082 100644 --- a/internal/app/renderer/table_renderer_test.go +++ b/internal/app/renderer/table_renderer_test.go @@ -4,8 +4,8 @@ import ( "strings" "testing" - "github.com/balajz/pgxcli/internal/config" "github.com/balaji01-4d/pgxspecial" + "github.com/balajz/pgxcli/internal/config" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/internal/app/ui/model.go b/internal/app/ui/model.go index 4ab3da7..8373a56 100644 --- a/internal/app/ui/model.go +++ b/internal/app/ui/model.go @@ -3,6 +3,7 @@ package ui import ( "bytes" + "context" "fmt" "os" "os/exec" @@ -48,6 +49,7 @@ type Model struct { // execute executes a query passed and return as ExecCmdMsg + ReadyMsg. execute func(string) tea.Cmd + cancel func(ctx context.Context) error } func New(initialPrefix string, pgKeywords []string, historyFile string, style string, executeFunc func(string) tea.Cmd) (*Model, error) { diff --git a/internal/cli/root.go b/internal/cli/root.go index 4eb5e82..71725c7 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -80,7 +80,7 @@ func NewRootCmd(ctx context.Context, cliCtx *CliContext) *cobra.Command { if !bool(interactiveConnFlag) { ui.PrintBanner(version) } - return cliCtx.App.Start(ctx, cliCtx.Client) + return cliCtx.App.Start(ctx) }, PersistentPostRunE: func(_ *cobra.Command, _ []string) error { @@ -358,7 +358,7 @@ func ensureConnected(cliCtx *CliContext) error { // which includes setting up the logger, config and autocompleter with PostgreSQL keywords. func initApplication(cliCtx *CliContext) error { completer := completer.New(cliCtx.Logger.Logger) - pgxCLI, err := app.New(cliCtx.config, cliCtx.Printer, cliCtx.Logger.Logger, completer) + pgxCLI, err := app.New(cliCtx.config, cliCtx.Printer, cliCtx.Logger.Logger, completer, cliCtx.Client) if err != nil { cliCtx.Logger.Error("Failed to initialize app", "error", err) return err From 99cec51c0885bba3a730eb56e2853553d7529cc6 Mon Sep 17 00:00:00 2001 From: Balaji J Date: Thu, 21 May 2026 14:15:19 +0530 Subject: [PATCH 3/6] add ctrl+c cancelation keybinding Signed-off-by: Balaji J --- internal/app/app.go | 16 +++++++++++--- internal/app/ui/model.go | 47 ++++++++++++++++++++++++++++++++++------ 2 files changed, 53 insertions(+), 10 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index f40a69b..241baa2 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -144,7 +144,14 @@ func (p *pgxCLI) Start(ctx context.Context) error { } initialPrefix := p.client.ParsePrompt(p.config.Main.Prompt) - m, err := ui.New(initialPrefix, p.completer.GetKeyWords(), p.config.Main.HistoryFile, string(p.config.Main.Style), executeFunc) + m, err := ui.New( + initialPrefix, + p.completer.GetKeyWords(), + p.config.Main.HistoryFile, + string(p.config.Main.Style), + executeFunc, + p.Cancel, + ) if err != nil { return fmt.Errorf("creating UI model: %w", err) } @@ -225,8 +232,11 @@ func (p *pgxCLI) handleSpecialCommand(ctx context.Context, metaResult pgxspecial } } -func (p *pgxCLI) cancel() error { - return nil +func (p *pgxCLI) Cancel(ctx context.Context) error { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + return p.client.Cancel(ctx) } func (p *pgxCLI) handleQueryResult(r result.Result) (tea.Cmd, error) { diff --git a/internal/app/ui/model.go b/internal/app/ui/model.go index 8373a56..6cadd81 100644 --- a/internal/app/ui/model.go +++ b/internal/app/ui/model.go @@ -39,6 +39,10 @@ type ReadyMsg struct{ Prefix string } // ExecCmdMsg is used to dispatch a batch/sequence of commands. type ExecCmdMsg struct{ Cmd tea.Cmd } +type cancel func(ctx context.Context) error + +type execute func(query string) tea.Cmd + type Model struct { input *editline.Model width, height int @@ -48,11 +52,11 @@ type Model struct { style string // execute executes a query passed and return as ExecCmdMsg + ReadyMsg. - execute func(string) tea.Cmd - cancel func(ctx context.Context) error + execute execute + cancel cancel } -func New(initialPrefix string, pgKeywords []string, historyFile string, style string, executeFunc func(string) tea.Cmd) (*Model, error) { +func New(initialPrefix string, pgKeywords []string, historyFile string, style string, executeFunc execute, cancelFunc cancel) (*Model, error) { el := editline.New(0, 0) el.Prompt = initialPrefix if historyFile == "" || historyFile == config.Default { @@ -68,6 +72,7 @@ func New(initialPrefix string, pgKeywords []string, historyFile string, style st historyFile: historyFile, style: style, execute: executeFunc, + cancel: cancelFunc, }, nil } @@ -99,12 +104,13 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case tea.KeyMsg: - if m.executing { - return m, nil - } - switch msg.String() { case "ctrl+c": + if m.executing { + m.cancel(context.Background()) + m.executing = false + } + m.input.Reset() return m, nil } @@ -254,6 +260,33 @@ func applyEditlineConfig(el *editline.Model, historyFile string, pgKeywords []st ) el.AutoComplete = postgresAutocomplete(pgKeywords) + // Configure multi-line input detection: + // A SQL query is only complete (submittable) if it is empty, starts with a backslash + // (special command), or ends with a semicolon ';'. Otherwise, Enter should just insert a newline. + el.CheckInputComplete = func(entireInput [][]rune, line, col int) bool { + var sb strings.Builder + for i, rline := range entireInput { + if i > 0 { + sb.WriteByte('\n') + } + sb.WriteString(string(rline)) + } + input := strings.TrimSpace(sb.String()) + + // If input is empty, let it submit (no-op) + if input == "" { + return true + } + + // If it's a special command (e.g. \clear, \d), it's single-line and complete + if strings.HasPrefix(input, "\\") { + return true + } + + // Otherwise, a SQL statement is complete only if it ends with a semicolon + return strings.HasSuffix(input, ";") + } + entries, err := history.LoadHistory(historyFile) if err != nil { return fmt.Errorf("loading history: %w", err) From bab5e541a15e182b8da98bcaaee5dbd6069dcf67 Mon Sep 17 00:00:00 2001 From: Balaji J Date: Thu, 21 May 2026 18:52:38 +0530 Subject: [PATCH 4/6] fix: async the query cancelation Signed-off-by: Balaji J --- internal/app/ui/model.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/internal/app/ui/model.go b/internal/app/ui/model.go index 6cadd81..9077923 100644 --- a/internal/app/ui/model.go +++ b/internal/app/ui/model.go @@ -106,12 +106,18 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyMsg: switch msg.String() { case "ctrl+c": + m.input.Reset() if m.executing { - m.cancel(context.Background()) m.executing = false + cancelFn := m.cancel + return m, func() tea.Msg { + if err := cancelFn(context.Background()); err != nil { + return ExecCmdMsg{Cmd: PrintErrCmd(err)} + } + return nil + } } - m.input.Reset() return m, nil } } From c414ebf2ed81997294023639830a019f5c50a22b Mon Sep 17 00:00:00 2001 From: Balaji J Date: Thu, 21 May 2026 18:59:44 +0530 Subject: [PATCH 5/6] update changelog Signed-off-by: Balaji J --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 182d3b6..1c051cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ ## [0.1.2] - 2026-05-20 +### Added +- **Query Cancellation**: Press `Ctrl+C` during a running query to cancel it immediately via PostgreSQL's out-of-band cancel signal. + ### Fixed - **hardcoded style**: Removed the hardcoded "monokai" style from the syntax highlighter. From f89546372772b2f7359e115cf4a77cbdb051451d Mon Sep 17 00:00:00 2001 From: Balaji J Date: Thu, 21 May 2026 19:13:30 +0530 Subject: [PATCH 6/6] test: add pgconn to mockconn --- internal/database/executor_test.go | 5 ++--- internal/database/mocks_test.go | 1 + 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/database/executor_test.go b/internal/database/executor_test.go index 652cd8f..586700d 100644 --- a/internal/database/executor_test.go +++ b/internal/database/executor_test.go @@ -185,9 +185,8 @@ func TestExecutorPing(t *testing.T) { withConn: true, }, { - name: "no connection", - wantErr: true, - wantErrMsg: "database not connected", + name: "no connection", + wantErr: true, }, } diff --git a/internal/database/mocks_test.go b/internal/database/mocks_test.go index ac7338b..950b604 100644 --- a/internal/database/mocks_test.go +++ b/internal/database/mocks_test.go @@ -31,6 +31,7 @@ func (mc *MockConn) Ping(ctx context.Context) error { func (mc *MockConn) QueryRow(_ context.Context, _ string, _ ...any) pgx.Row { return nil } func (mc *MockConn) Close(_ context.Context) error { return nil } func (mc *MockConn) Config() *pgx.ConnConfig { return nil } +func (mc *MockConn) PgConn() *pgconn.PgConn { return nil } type MockRows struct { mock.Mock