diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c051cf..903674e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ ## [Unreleased] +## [0.2.0] - 2026-05-22 + +### Added +- **Modernized UI**: Completely redesigned REPL interface with styles and better visual hierarchy. +- **Orca Banner**: Colorful ASCII orca banner with gradient styling on startup. +- **Report Issue link**: Clickable link in the status bar to report issues on GitHub and banner. +- **Loading Spinner**: Visual spinner indicator that displays during query execution. + +### Fixed +- **Version update**: Updated version string. +- **fix interactive page**: fix the interactive page to show only form when width is lesser. + +### Refactored +- **UI Component Architecture**: Extracted child components (Input, Status, Spinner) into separate modules for better maintainability. +- **Style Management**: Separated style definitions into dedicated source files for cleaner organization. + ## [0.1.2] - 2026-05-20 ### Added @@ -39,4 +55,4 @@ The first release of pgxcli, a command-line interface for PostgreSQL inspired by - configurable syntax highlighting - Support for multiple table formats - Linux packages: deb, rpm, apk, archlinux -- Windows MSI installer \ No newline at end of file +- Windows MSI installer diff --git a/internal/app/app.go b/internal/app/app.go index 241baa2..942acf5 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -8,13 +8,14 @@ import ( "context" "fmt" "log/slog" + "os" "strconv" "strings" "time" tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/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" "github.com/balajz/pgxcli/internal/cliio" @@ -34,8 +35,8 @@ type Application interface { Close() error } -var builtinsCommand = map[string]func(){ - "\\clear": commands.ClearScreen, +var builtinsCommand = map[string]func() tea.Cmd{ + "\\clear": func() tea.Cmd { return tea.ClearScreen }, } // pgxCLI is the main implementation of the Application interface. @@ -47,15 +48,18 @@ type pgxCLI struct { logger *slog.Logger completer *completer.Completer client *database.Client + + version string } -func New(cfg *config.Config, printer cliio.Printer, logger *slog.Logger, completer *completer.Completer, client *database.Client) (Application, error) { +func New(cfg *config.Config, printer cliio.Printer, logger *slog.Logger, completer *completer.Completer, client *database.Client, version string) (Application, error) { return &pgxCLI{ config: cfg, logger: logger, Printer: printer, completer: completer, client: client, + version: version, }, nil } @@ -69,8 +73,7 @@ func (p *pgxCLI) execute(ctx context.Context, query string) tea.Cmd { if cmd, ok := builtinsCommand[query]; ok { p.logger.Debug("executing builtin command", "command", query) - cmd() - return promptReady + return tea.Sequence(cmd(), promptReady) } return func() tea.Msg { @@ -85,7 +88,9 @@ func (p *pgxCLI) execute(ctx context.Context, query string) tea.Cmd { 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} + return ui.ExecCmdMsg{Cmd: func() tea.Msg { + return ui.QuitRequestMsg{} + }} } if err != nil { @@ -139,6 +144,7 @@ func (p *pgxCLI) execute(ctx context.Context, query string) tea.Cmd { } func (p *pgxCLI) Start(ctx context.Context) error { + p.printBanner(p.version) executeFunc := func(query string) tea.Cmd { return p.execute(ctx, query) } @@ -149,6 +155,7 @@ func (p *pgxCLI) Start(ctx context.Context) error { p.completer.GetKeyWords(), p.config.Main.HistoryFile, string(p.config.Main.Style), + p.version, executeFunc, p.Cancel, ) @@ -239,6 +246,10 @@ func (p *pgxCLI) Cancel(ctx context.Context) error { return p.client.Cancel(ctx) } +func (p *pgxCLI) printBanner(version string) { + lipgloss.Fprint(os.Stdout, ui.Banner(version)+"\n") +} + func (p *pgxCLI) handleQueryResult(r result.Result) (tea.Cmd, error) { res, ok := r.(*result.QueryResult) if !ok { @@ -268,16 +279,16 @@ func (p *pgxCLI) printViaPager(str string) tea.Cmd { if p.Printer.ShouldUsePager(str) { cmd, ok := cliio.PagerCmd(str) if !ok { - return ui.PrintCmd(str) + return ui.PrintCmd(str, ui.DefaultStyles().AppOutput) } return ui.ShowPagerCmd(cmd) } - return ui.PrintCmd(str) + return ui.PrintCmd(str, ui.DefaultStyles().AppOutput) } func (p *pgxCLI) printError(err error) tea.Cmd { - return ui.PrintErrCmd(err) + return ui.PrintErrCmd(err, ui.DefaultStyles().ErrorOutput) } func (p *pgxCLI) Close() error { diff --git a/internal/app/commands/clear_screen.go b/internal/app/commands/clear_screen.go deleted file mode 100644 index a06682e..0000000 --- a/internal/app/commands/clear_screen.go +++ /dev/null @@ -1,13 +0,0 @@ -// Package commands provides implementations for various commands -// that can be executed in the pgxCLI application. -package commands - -import "fmt" - -const escCode = "\033[2J\033[H\033[3J" - -// ClearScreen clears the terminal screen -// by printing the appropriate escape code. -func ClearScreen() { - fmt.Print(escCode) -} diff --git a/internal/ui/ascii.txt b/internal/app/ui/ascii.txt similarity index 88% rename from internal/ui/ascii.txt rename to internal/app/ui/ascii.txt index 4711374..1b5e968 100644 --- a/internal/ui/ascii.txt +++ b/internal/app/ui/ascii.txt @@ -1,4 +1,3 @@ - ::: :::: . :::: ::::::::::::: :::: :::: ::: @@ -12,4 +11,4 @@ :::-▆▆▆▆▆▆▆▆▆;::;;;;;;;:::▆▆▆▆ :::: ▆▆▆▆::::;;;;;::; .::: ;::::: - ;::: + ;::: \ No newline at end of file diff --git a/internal/app/ui/banner.go b/internal/app/ui/banner.go new file mode 100644 index 0000000..f48f54e --- /dev/null +++ b/internal/app/ui/banner.go @@ -0,0 +1,128 @@ +package ui + +import ( + _ "embed" + "fmt" + "os" + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/term" +) + +var issueLink = "https://github.com/balajz/pgxcli/issues" + +var ( + primaryStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#8B5CF6")). + Bold(true) + + secondaryStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#A78BFA")) + + mutedStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#A1A1AA")) + + accentStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#C4B5FD")). + Italic(true) + + linkStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#7C3AED")). + Underline(true) +) + +//go:embed ascii.txt +var asciiArt string + +func gradientColor(t float64) (r, g, b int) { + type rgb = [3]float64 + pine := rgb{62, 143, 176} + foam := rgb{156, 207, 216} + iris := rgb{196, 167, 231} + + lerp := func(a, b, t float64) float64 { return a + t*(b-a) } + + var c1, c2 rgb + var t2 float64 + if t <= 0.5 { + c1, c2, t2 = pine, foam, t*2 + } else { + c1, c2, t2 = foam, iris, (t-0.5)*2 + } + + return int(lerp(c1[0], c2[0], t2)), + int(lerp(c1[1], c2[1], t2)), + int(lerp(c1[2], c2[2], t2)) +} + +func orcaStr() string { + lines := strings.Split(asciiArt, "\n") + for len(lines) > 0 && strings.TrimSpace(lines[0]) == "" { + lines = lines[1:] + } + for len(lines) > 0 && strings.TrimSpace(lines[len(lines)-1]) == "" { + lines = lines[:len(lines)-1] + } + for i, line := range lines { + lines[i] = strings.TrimRight(line, " ") + } + + total := len(lines) + var sb strings.Builder + + for i, line := range lines { + if i > 0 { + sb.WriteByte('\n') + } + + t := 0.0 + if total > 1 { + t = float64(i) / float64(total-1) + } + r, g, b := gradientColor(t) + hexColor := lipgloss.Color(fmt.Sprintf("#%02x%02x%02x", r, g, b)) + style := lipgloss.NewStyle().Foreground(hexColor) + + sb.WriteString(style.Render(line)) + } + + return sb.String() +} + +func Banner(version string) string { + leftPane := orcaStr() + + rightPane := lipgloss.JoinVertical( + lipgloss.Left, + secondaryStyle.Render("welcome to ")+ + primaryStyle.Render("pgxcli ")+ + mutedStyle.Render("v"+version)+"\n\n", + + accentStyle.Render("Happy Postgresing!\n\n"), + + linkStyle. + Hyperlink(issueLink). + Render("Report Issues ↗"), + ) + + content := lipgloss.JoinHorizontal( + lipgloss.Top, + leftPane, + " ", // gap between art and text + rightPane, + ) + + banner := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#8B5CF6")). + Padding(1, 4). + Render(content) + + w, _, err := term.GetSize(os.Stdout.Fd()) + if err == nil && w > 0 { + banner = lipgloss.Place(w, lipgloss.Height(banner), lipgloss.Center, lipgloss.Top, banner) + } + + return banner +} diff --git a/internal/app/ui/components/input.go b/internal/app/ui/components/input.go new file mode 100644 index 0000000..47f8d71 --- /dev/null +++ b/internal/app/ui/components/input.go @@ -0,0 +1,180 @@ +package components + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "strings" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "github.com/Balaji01-4D/bubbline/computil" + "github.com/Balaji01-4D/bubbline/editline" + "github.com/Balaji01-4D/bubbline/history" + "github.com/alecthomas/chroma/v2/quick" + "github.com/muesli/termenv" +) + +var chromaFormatter = detectTerminalColorProfile() + +// InputModel wraps the editline component. +type InputModel struct { + Model *editline.Model + HistoryFile string +} + +// NewInputModel creates and configures the input model. +func NewInputModel(prompt, historyFile string, pgKeywords []string, style string) (*InputModel, error) { + el := editline.New(1, 1) + el.Prompt = prompt + + if historyFile == "" || historyFile == "default" { + historyFile = getHistoryFilePath() + } + + if err := applyEditlineConfig(el, historyFile, pgKeywords, style); err != nil { + return nil, fmt.Errorf("applying input config: %w", err) + } + + return &InputModel{ + Model: el, + HistoryFile: historyFile, + }, nil +} + +func (m *InputModel) Init() tea.Cmd { + return m.Model.Focus() +} + +func (m *InputModel) Update(msg tea.Msg) (InputModel, tea.Cmd) { + newModel, cmd := m.Model.Update(msg) + m.Model = newModel + return *m, cmd +} + +func (m *InputModel) View() string { + return m.Model.View() +} + +func (m *InputModel) SetSize(width, height int) { + m.Model.SetSize(width, height) +} + +func (m *InputModel) Value() string { + return m.Model.Value() +} + +func (m *InputModel) Reset() { + m.Model.Reset() +} + +func (m *InputModel) AddHistoryEntry(entry string) { + m.Model.AddHistoryEntry(entry) +} + +func (m *InputModel) SaveHistory() error { + if m.HistoryFile == "" { + return nil + } + return history.SaveHistory(m.Model.GetHistory(), m.HistoryFile) +} + +func (m *InputModel) SetPrompt(prompt string) { + m.Model.Prompt = prompt +} + +func (m *InputModel) Prompt() string { + return m.Model.Prompt +} + +func postgresHighlighter(style string) func(string) string { + return func(s string) string { + var buf bytes.Buffer + if err := quick.Highlight(&buf, s, "postgresql", chromaFormatter, style); err != nil { + return s + } + return buf.String() + } +} + +func postgresAutocomplete(pgKeywords []string) func(v [][]rune, line, col int) (string, editline.Completions) { + return func(v [][]rune, line, col int) (string, editline.Completions) { + word, wstart, wend := computil.FindWord(v, line, col) + if word == "" { + return "", nil + } + upperWord := strings.ToUpper(word) + var matches []string + for _, kw := range pgKeywords { + if strings.HasPrefix(kw, upperWord) { + matches = append(matches, kw) + } + } + if len(matches) == 0 { + return "", nil + } + return "", editline.SimpleWordsCompletion(matches, "Keywords", col, wstart, wend) + } +} + +func detectTerminalColorProfile() string { + switch termenv.ColorProfile() { + case termenv.TrueColor: + return "terminal16m" + case termenv.ANSI256: + return "terminal256" + case termenv.ANSI: + return "terminal16" + default: + return "noop" + } +} + +func applyEditlineConfig(el *editline.Model, historyFile string, pgKeywords []string, style string) error { + el.SetHelpDisabled(true) + el.SetHighlighter(postgresHighlighter(style)) + el.SetExternalEditorEnabled(true, "sql") + el.KeyMap.ExternalEdit = key.NewBinding( + key.WithKeys("ctrl+e"), + key.WithHelp("ctrl+e", "edit query in external editor"), + ) + el.AutoComplete = postgresAutocomplete(pgKeywords) + + 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 == "" { + return true + } + + if strings.HasPrefix(input, "\\") { + return true + } + + return strings.HasSuffix(input, ";") + } + + entries, err := history.LoadHistory(historyFile) + if err != nil { + return fmt.Errorf("loading history: %w", err) + } + + el.SetHistory(entries) + return nil +} + +func getHistoryFilePath() string { + homeDir, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(homeDir, ".pgxcli_history.jsonl") +} diff --git a/internal/app/ui/components/spinner.go b/internal/app/ui/components/spinner.go new file mode 100644 index 0000000..949e581 --- /dev/null +++ b/internal/app/ui/components/spinner.go @@ -0,0 +1,53 @@ +package components + +import ( + "charm.land/bubbles/v2/spinner" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +type SpinnerModel struct { + spinner spinner.Model + caption string + + spinnerStyle lipgloss.Style + captionStyle lipgloss.Style +} + +func NewSpinnerModel(spinnerStyle, captionStyle lipgloss.Style) SpinnerModel { + sp := spinner.New() + sp.Spinner = spinner.Dot + sp.Style = spinnerStyle + + return SpinnerModel{ + spinner: sp, + caption: "Postgresing...", + spinnerStyle: spinnerStyle, + captionStyle: captionStyle, + } +} + +func (m SpinnerModel) Init() tea.Cmd { + return m.spinner.Tick +} + +func (m SpinnerModel) Tick() tea.Cmd { + return m.spinner.Tick +} + +func (m SpinnerModel) Update(msg tea.Msg) (SpinnerModel, tea.Cmd) { + var cmd tea.Cmd + switch msg := msg.(type) { + case spinner.TickMsg: + m.spinner, cmd = m.spinner.Update(msg) + } + return m, cmd +} + +func (m SpinnerModel) View() string { + return lipgloss.JoinHorizontal( + lipgloss.Left, + m.spinner.View(), + m.captionStyle.Render(m.caption), + ) +} diff --git a/internal/app/ui/components/status.go b/internal/app/ui/components/status.go new file mode 100644 index 0000000..d6b8eb5 --- /dev/null +++ b/internal/app/ui/components/status.go @@ -0,0 +1,82 @@ +package components + +import ( + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +// StatusModel manages the status bar and the executing spinner. +type StatusModel struct { + Version string + Width int + IssueLink string + SeparatorStyle lipgloss.Style + StatusBarStyle lipgloss.Style +} + +func NewStatusModel(version, issueLink string) StatusModel { + return StatusModel{ + Version: version, + IssueLink: issueLink, + } +} + +func (m StatusModel) Init() tea.Cmd { + return nil +} + +func (m StatusModel) Update(msg tea.Msg) (StatusModel, tea.Cmd) { + var cmd tea.Cmd + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.Width = msg.Width + } + return m, cmd +} + +// ViewFooter returns the static footer with separator and status bar. +func (m StatusModel) View() string { + if m.Width == 0 { + return "" + } + + name := "pgxcli " + m.Version + + link := m.StatusBarStyle. + Underline(true). + Hyperlink(m.IssueLink). + Render("Report Issue") + + separator := m.SeparatorStyle.Render(strings.Repeat("─", m.Width)) + + innerWidth := m.Width - m.StatusBarStyle.GetHorizontalPadding() + if innerWidth < 0 { + innerWidth = 0 + } + + usedWidth := lipgloss.Width(name) + lipgloss.Width(link) + paddingWidth := innerWidth - usedWidth + if paddingWidth < 0 { + paddingWidth = 0 + } + padding := strings.Repeat(" ", paddingWidth) + + statusBar := m.StatusBarStyle.Width(m.Width).Render(name + padding + link) + + return lipgloss.JoinVertical( + lipgloss.Top, + separator, + statusBar, + ) +} + +// StaticHeight returns the height of the footer. +func (m StatusModel) StaticHeight() int { + separator := m.SeparatorStyle.Render(strings.Repeat("─", m.Width)) + statusBar := m.StatusBarStyle.Width(m.Width).Render("pgxcli " + m.Version) + + // Top separator + Bottom separator + Status bar + return lipgloss.Height(separator)*2 + lipgloss.Height(statusBar) +} diff --git a/internal/ui/forms.go b/internal/app/ui/forms.go similarity index 74% rename from internal/ui/forms.go rename to internal/app/ui/forms.go index 3f6c003..27cf28b 100644 --- a/internal/ui/forms.go +++ b/internal/app/ui/forms.go @@ -33,9 +33,9 @@ func newStyles(hasDarkBg bool) *styles { lightDark = lipgloss.LightDark(hasDarkBg) ) - s.Red = lightDark(lipgloss.Color("#FE5F86"), lipgloss.Color("#FE5F86")) - s.Indigo = lightDark(lipgloss.Color("#5A56E0"), lipgloss.Color("#7571F9")) - s.Green = lightDark(lipgloss.Color("#02BA84"), lipgloss.Color("#02BF87")) + s.Red = lightDark(lipgloss.Color("#FF6B6B"), lipgloss.Color("#FF6B6B")) + s.Indigo = lightDark(lipgloss.Color("#8B5CF6"), lipgloss.Color("#A78BFA")) + s.Green = lightDark(lipgloss.Color("#A78BFA"), lipgloss.Color("#C4B5FD")) s.Base = lipgloss.NewStyle(). Padding(1, 4, 0, 1) s.HeaderText = lipgloss.NewStyle(). @@ -51,7 +51,7 @@ func newStyles(hasDarkBg bool) *styles { Foreground(s.Green). Bold(true) s.Highlight = lipgloss.NewStyle(). - Foreground(lipgloss.Color("212")) + Foreground(lipgloss.Color("#B8A2FF")) s.ErrorHeaderText = s.HeaderText. Foreground(s.Red) s.Help = lipgloss.NewStyle(). @@ -127,6 +127,7 @@ func newModel(database, username, host, port string) *model { Value(&m.values.Password), ), ). + WithTheme(pgxcliTheme{}). WithWidth(45). WithShowHelp(false). WithShowErrors(false) @@ -188,7 +189,7 @@ func (m *model) View() tea.View { return view default: // Orca (left side) - orca := lipgloss.NewStyle().Margin(1, 4, 0, 0).Render(orcaView()) + orca := lipgloss.NewStyle().Margin(1, 4, 0, 0).Render(orcaStr()) // Form card (right side) v := strings.TrimSuffix(m.form.View(), "\n\n") @@ -219,7 +220,13 @@ func (m *model) View() tea.View { if len(errors) > 0 { header = m.appErrorBoundaryView(m.errorView()) } - body := lipgloss.JoinHorizontal(lipgloss.Center, orca, rightPanel) + totalWidthReq := lipgloss.Width(orca) + lipgloss.Width(rightPanel) + s.Base.GetHorizontalFrameSize() + var body string + if m.termWidth > 0 && m.termWidth < totalWidthReq { + body = rightPanel + } else { + body = lipgloss.JoinHorizontal(lipgloss.Center, orca, rightPanel) + } footer := m.appBoundaryView(m.form.Help().ShortHelpView(m.form.KeyBinds())) if len(errors) > 0 { @@ -345,3 +352,42 @@ func validatePort(v string) error { } return nil } + +type pgxcliTheme struct{} + +func (t pgxcliTheme) Theme(hasDarkBg bool) *huh.Styles { + s := huh.ThemeBase(hasDarkBg) + + primary := lipgloss.Color("#A78BFA") + secondary := lipgloss.Color("#C4B5FD") + border := lipgloss.Color("#8B5CF6") + errorFg := lipgloss.Color("#FF6B6B") + + s.Focused.Base = s.Focused.Base.BorderForeground(border) + s.Focused.Title = s.Focused.Title.Foreground(primary).Bold(true) + s.Focused.NoteTitle = s.Focused.NoteTitle.Foreground(primary).Bold(true) + s.Focused.Directory = s.Focused.Directory.Foreground(primary) + s.Focused.Description = s.Focused.Description.Foreground(secondary) + s.Focused.ErrorIndicator = s.Focused.ErrorIndicator.Foreground(errorFg) + s.Focused.ErrorMessage = s.Focused.ErrorMessage.Foreground(errorFg) + s.Focused.SelectSelector = s.Focused.SelectSelector.Foreground(primary) + s.Focused.NextIndicator = s.Focused.NextIndicator.Foreground(primary) + s.Focused.PrevIndicator = s.Focused.PrevIndicator.Foreground(secondary) + s.Focused.Option = s.Focused.Option.Foreground(secondary) + s.Focused.MultiSelectSelector = s.Focused.MultiSelectSelector.Foreground(primary) + s.Focused.SelectedOption = s.Focused.SelectedOption.Foreground(primary) + s.Focused.SelectedPrefix = s.Focused.SelectedPrefix.Foreground(primary) + s.Focused.UnselectedPrefix = s.Focused.UnselectedPrefix.Foreground(secondary) + s.Focused.UnselectedOption = s.Focused.UnselectedOption.Foreground(secondary) + s.Focused.FocusedButton = s.Focused.FocusedButton.Foreground(lipgloss.Color("#2A273F")).Background(primary) + s.Focused.BlurredButton = s.Focused.BlurredButton.Foreground(secondary).Background(lipgloss.Color("#2A273F")) + + s.Focused.TextInput.Cursor = s.Focused.TextInput.Cursor.Foreground(primary) + s.Focused.TextInput.Placeholder = s.Focused.TextInput.Placeholder.Foreground(lipgloss.Color("240")) + s.Focused.TextInput.Prompt = s.Focused.TextInput.Prompt.Foreground(primary) + + s.Blurred = s.Focused + s.Blurred.Base = s.Blurred.Base.BorderStyle(lipgloss.HiddenBorder()) + + return s +} diff --git a/internal/ui/forms_test.go b/internal/app/ui/forms_test.go similarity index 100% rename from internal/ui/forms_test.go rename to internal/app/ui/forms_test.go diff --git a/internal/app/ui/keys.go b/internal/app/ui/keys.go new file mode 100644 index 0000000..e86dd89 --- /dev/null +++ b/internal/app/ui/keys.go @@ -0,0 +1,23 @@ +package ui + +import "charm.land/bubbles/v2/key" + +// KeyMap defines the keybindings for the application. +type KeyMap struct { + Quit key.Binding + Interrupt key.Binding +} + +// DefaultKeyMap returns the default keybindings. +func DefaultKeyMap() KeyMap { + return KeyMap{ + Quit: key.NewBinding( + key.WithKeys("ctrl+d"), + key.WithHelp("ctrl+d", "quit"), + ), + Interrupt: key.NewBinding( + key.WithKeys("ctrl+c"), + key.WithHelp("ctrl+c", "interrupt"), + ), + } +} diff --git a/internal/app/ui/model.go b/internal/app/ui/model.go index 5253781..997975a 100644 --- a/internal/app/ui/model.go +++ b/internal/app/ui/model.go @@ -7,30 +7,26 @@ import ( "fmt" "os" "os/exec" - "path/filepath" "strings" "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/spinner" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" - "github.com/Balaji01-4D/bubbline/computil" "github.com/Balaji01-4D/bubbline/editline" - "github.com/Balaji01-4D/bubbline/history" "github.com/alecthomas/chroma/v2/quick" - "github.com/balajz/pgxcli/internal/config" + "github.com/balajz/pgxcli/internal/app/ui/components" + "github.com/davecgh/go-spew/spew" "github.com/muesli/termenv" ) var chromaFormatter = detectTerminalColorProfile() -var ( - userInputStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#908CAA")) - appOutputStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#E0DEF4")) - errorOutputStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")) - statusBarStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#908CAA")). - Background(lipgloss.Color("#2A273F")). - Padding(0, 1) +type State int + +const ( + StateInput State = iota + StateExecuting ) // ReadyMsg signals the ui that execution is done and it should prompt. @@ -39,92 +35,170 @@ type ReadyMsg struct{ Prefix string } // ExecCmdMsg is used to dispatch a batch/sequence of commands. type ExecCmdMsg struct{ Cmd tea.Cmd } +// QuitRequestMsg signals that the app wants to quit. +type QuitRequestMsg struct{} + +// ConfirmQuitMsg is used internally to finalize quitting. +type ConfirmQuitMsg struct{} + type cancel func(ctx context.Context) error type execute func(query string) tea.Cmd type Model struct { - input *editline.Model + input components.InputModel + statusModel components.StatusModel + spinner components.SpinnerModel + isSpinning bool width, height int - executing bool + state State + quitting bool prevUserInput string - historyFile string - style string + version string + highlighter func(string) string + + keys KeyMap + styles Styles // execute executes a query passed and return as ExecCmdMsg + ReadyMsg. execute execute cancel cancel + + dump *os.File } -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 { - historyFile = getHistoryFilePath() +func New(initialPrefix string, pgKeywords []string, historyFile string, style string, version string, executeFunc execute, cancelFunc cancel) (*Model, error) { + inputModel, err := components.NewInputModel(initialPrefix, historyFile, pgKeywords, style) + if err != nil { + return nil, fmt.Errorf("creating input model: %w", err) } - if err := applyEditlineConfig(el, historyFile, pgKeywords, style); err != nil { - return nil, fmt.Errorf("applying input config: %w", err) + styles := DefaultStyles() + + statusModel := components.NewStatusModel(version, issueLink) + statusModel.SeparatorStyle = styles.InputSeparator + statusModel.StatusBarStyle = styles.StatusBar + + spinnerModel := components.NewSpinnerModel(styles.Spinner, styles.SpinnerCaption) + + var dump *os.File + if _, ok := os.LookupEnv("PGXCLI_DEBUG"); ok { + dump, err = os.OpenFile("pgxcli_messages.log", os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644) + if err != nil { + return nil, fmt.Errorf("opening debug log: %w", err) + } } return &Model{ - input: el, - historyFile: historyFile, - style: style, + input: *inputModel, + statusModel: statusModel, + spinner: spinnerModel, + version: version, + state: StateInput, + keys: DefaultKeyMap(), + styles: styles, execute: executeFunc, cancel: cancelFunc, + dump: dump, + highlighter: postgresHighlighter(style), }, nil } func (m *Model) Init() tea.Cmd { - return m.input.Focus() + return tea.Batch( + m.input.Init(), + m.statusModel.Init(), + m.spinner.Init(), + ) } +//nolint:gocyclo func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if m.dump != nil { + spew.Fdump(m.dump, msg) + } + + var cmds []tea.Cmd + + // Send WindowSize to children switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + var smCmd tea.Cmd + m.statusModel, smCmd = m.statusModel.Update(msg) + cmds = append(cmds, smCmd) + m.updateInputSize() + return m, tea.Batch(cmds...) + } + + switch msg := msg.(type) { + + case QuitRequestMsg: + m.quitting = true + return m, func() tea.Msg { + return ConfirmQuitMsg{} + } + + case ConfirmQuitMsg: + return m, tea.Quit case ReadyMsg: - m.executing = false + m.state = StateInput + m.isSpinning = false if msg.Prefix != "" { - m.input.Prompt = msg.Prefix + m.input.SetPrompt(msg.Prefix) } - m.input.Reset() return m, nil case ExecCmdMsg: return m, msg.Cmd + case spinner.TickMsg: + if m.isSpinning { + var smCmd tea.Cmd + m.spinner, smCmd = m.spinner.Update(msg) + return m, smCmd + } + return m, nil + case editline.InputCompleteMsg: + if m.state == StateExecuting { + return m, nil + } return m.handleInput() - case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - m.input.SetSize(msg.Width, msg.Height-4) - return m, nil - case tea.KeyMsg: - switch msg.String() { - case "ctrl+c": - m.input.Reset() - if m.executing { - m.executing = false + if key.Matches(msg, m.keys.Quit) { + return m, func() tea.Msg { + return QuitRequestMsg{} + } + } + if key.Matches(msg, m.keys.Interrupt) { + if m.state == StateExecuting { + m.state = StateInput cancelFn := m.cancel return m, func() tea.Msg { if err := cancelFn(context.Background()); err != nil { - return ExecCmdMsg{Cmd: PrintErrCmd(err)} + return ExecCmdMsg{Cmd: PrintErrCmd(err, m.styles.ErrorOutput)} } return nil } } + m.input.Reset() return m, nil } } - var nextCmd tea.Cmd - m.input, nextCmd = m.input.Update(msg) - return m, nextCmd + // Route to input only if in input state, avoiding capturing keystrokes while executing. + if m.state == StateInput { + var nextCmd tea.Cmd + m.input, nextCmd = m.input.Update(msg) + cmds = append(cmds, nextCmd) + } + + return m, tea.Batch(cmds...) } func (m *Model) handleInput() (tea.Model, tea.Cmd) { @@ -132,53 +206,92 @@ func (m *Model) handleInput() (tea.Model, tea.Cmd) { trimmed := strings.TrimSpace(input) if trimmed == "" { + m.input.Reset() return m, tea.Sequence( - m.printUserInput(userInputStyle.Render(m.input.Prompt), ""), + m.printUserInput(m.styles.UserInput.Render(m.input.Prompt()), ""), func() tea.Msg { - return ReadyMsg{Prefix: m.input.Prompt} + return ReadyMsg{Prefix: m.input.Prompt()} }, ) } m.prevUserInput = input - m.executing = true + m.state = StateExecuting + m.isSpinning = true m.input.AddHistoryEntry(input) + m.input.Reset() return m, tea.Sequence( - m.printUserInput(userInputStyle.Render(m.input.Prompt), input), + m.printUserInput(m.styles.UserInput.Render(m.input.Prompt()), input), + m.spinner.Tick(), m.execute(trimmed), ) } func (m *Model) printUserInput(prefix, input string) tea.Cmd { - var highlightedInput string - if input != "" { - highlightedInput = postgresHighlighter(m.style)(input) + return func() tea.Msg { + var highlightedInput string + if input != "" { + highlightedInput = m.highlighter(input) + } + + // used to separate previous user input from the current one with half straight line. + line := strings.Repeat("─", m.width/2) + + userContent := lipgloss.JoinHorizontal(lipgloss.Left, prefix, highlightedInput) + userContent = lipgloss.JoinVertical(lipgloss.Top, m.styles.UserInputSepartor.Render(line), userContent) + + return ExecCmdMsg{Cmd: tea.Printf("%s", userContent)} } +} - userContent := lipgloss.JoinHorizontal(lipgloss.Left, userInputStyle.Render(prefix), highlightedInput) - return tea.Printf("%s", userContent) +func (m *Model) updateInputSize() { + if m.width == 0 || m.height == 0 { + return + } + // Calculate available height for input + h := m.height - m.statusModel.StaticHeight() + if h < 1 { + h = 1 + } + m.input.SetSize(m.width, h) } func (m *Model) View() tea.View { - statusStyle := statusBarStyle.Width(m.width) - if m.executing { - statusBar := statusStyle.AlignVertical(lipgloss.Bottom).Render("pgxcli") - return tea.NewView(statusBar) + if m.quitting { + return tea.NewView("") + } + // Don't render until we know the terminal size. + if m.width == 0 { + return tea.NewView("") + } + + var baseView string + + if m.state == StateExecuting { + baseView = m.spinner.View() + } else { + separator := m.statusModel.SeparatorStyle.Render(strings.Repeat("─", m.width)) + + baseView = lipgloss.JoinVertical( + lipgloss.Top, + separator, + m.input.View(), + m.statusModel.View(), + ) } - str := lipgloss.Sprintf("%s\n%s", m.input.View(), statusStyle.Render("pgxcli")) - return tea.NewView(str) + return tea.NewView(baseView) } func (m *Model) saveHistory() error { - if m.historyFile == "" { - return nil - } - return history.SaveHistory(m.input.GetHistory(), m.historyFile) + return m.input.SaveHistory() } func (m *Model) Close() error { + if m.dump != nil { + _ = m.dump.Close() + } if err := m.saveHistory(); err != nil { return fmt.Errorf("saving history: %w", err) } @@ -186,19 +299,19 @@ func (m *Model) Close() error { } // PrintCmd returns a command that prints formatted text. -func PrintCmd(text string) tea.Cmd { +func PrintCmd(text string, style lipgloss.Style) tea.Cmd { formattedInteraction := lipgloss.Sprintf( "%s\n", - appOutputStyle.Render(text), + style.Render(text), ) return tea.Printf("%s", formattedInteraction) } // PrintErrCmd returns a command that prints a formatted error. -func PrintErrCmd(err error) tea.Cmd { +func PrintErrCmd(err error, style lipgloss.Style) tea.Cmd { formattedError := lipgloss.Sprintf( "%s\n", - errorOutputStyle.Render("✗ "+err.Error()), + style.Render("✗ "+err.Error()), ) return tea.Printf("%s", formattedError) } @@ -223,26 +336,6 @@ func postgresHighlighter(style string) func(string) string { } } -func postgresAutocomplete(pgKeywords []string) func(v [][]rune, line, col int) (string, editline.Completions) { - return func(v [][]rune, line, col int) (string, editline.Completions) { - word, wstart, wend := computil.FindWord(v, line, col) - if word == "" { - return "", nil - } - upperWord := strings.ToUpper(word) - var matches []string - for _, kw := range pgKeywords { - if strings.HasPrefix(kw, upperWord) { - matches = append(matches, kw) - } - } - if len(matches) == 0 { - return "", nil - } - return "", editline.SimpleWordsCompletion(matches, "Keywords", col, wstart, wend) - } -} - func detectTerminalColorProfile() string { switch termenv.ColorProfile() { case termenv.TrueColor: @@ -255,51 +348,3 @@ func detectTerminalColorProfile() string { return "noop" } } - -func applyEditlineConfig(el *editline.Model, historyFile string, pgKeywords []string, style string) error { - el.SetHelpDisabled(true) - el.SetHighlighter(postgresHighlighter(style)) - el.SetExternalEditorEnabled(true, "sql") - el.KeyMap.ExternalEdit = key.NewBinding( - key.WithKeys("ctrl+e"), - key.WithHelp("ctrl+e", "edit query in external editor"), - ) - el.AutoComplete = postgresAutocomplete(pgKeywords) - - 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 == "" { - return true - } - - if strings.HasPrefix(input, "\\") { - return true - } - - return strings.HasSuffix(input, ";") - } - - entries, err := history.LoadHistory(historyFile) - if err != nil { - return fmt.Errorf("loading history: %w", err) - } - - el.SetHistory(entries) - return nil -} - -func getHistoryFilePath() string { - homeDir, err := os.UserHomeDir() - if err != nil { - return "" - } - return filepath.Join(homeDir, ".pgxcli_history.jsonl") -} diff --git a/internal/app/ui/styles.go b/internal/app/ui/styles.go new file mode 100644 index 0000000..0e98912 --- /dev/null +++ b/internal/app/ui/styles.go @@ -0,0 +1,52 @@ +package ui + +import "charm.land/lipgloss/v2" + +type Styles struct { + // UserInput styles the user's input query after enter key is pressed. + UserInput lipgloss.Style + // AppOutput styles the output from the application after executing the query. + AppOutput lipgloss.Style + // ErrorOutput styles the error output from the application after executing the query. + ErrorOutput lipgloss.Style + + // UserInputSepartor styles the separator between user input from previous result. + UserInputSepartor lipgloss.Style + + // InputSeparator is a top and bottom border for editline input area. + InputSeparator lipgloss.Style + + // Spinner styles the spinner animation. + Spinner lipgloss.Style + SpinnerCaption lipgloss.Style + + // StatusBar styles the status bar at the bottom. + StatusBar lipgloss.Style +} + +func DefaultStyles() Styles { + return Styles{ + UserInput: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#A78BFA")), + AppOutput: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#C4B5FD")), + ErrorOutput: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FF6B6B")), + UserInputSepartor: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#B8A2FF")), + InputSeparator: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#8B5CF6")), + Spinner: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#C4B5FD")). + PaddingLeft(2). + Bold(true), + SpinnerCaption: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#A78BFA")). + Italic(true). + Faint(true), + StatusBar: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#C4B5FD")). + Background(lipgloss.Color("#2A273F")). + Padding(0, 1), + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 71725c7..8c711f0 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -12,16 +12,16 @@ import ( "github.com/balajz/pgxcli/internal/app" "github.com/balajz/pgxcli/internal/app/renderer" + "github.com/balajz/pgxcli/internal/app/ui" "github.com/balajz/pgxcli/internal/completer" "github.com/balajz/pgxcli/internal/config" "github.com/balajz/pgxcli/internal/database" "github.com/balajz/pgxcli/internal/logger" - "github.com/balajz/pgxcli/internal/ui" "github.com/spf13/cobra" ) var ( - version = "0.1.1" + version = "0.2.0" osUser = osUsername() ) @@ -77,9 +77,6 @@ func NewRootCmd(ctx context.Context, cliCtx *CliContext) *cobra.Command { cliCtx.Logger.Error("Application context not initialized") return fmt.Errorf("application context not initialized") } - if !bool(interactiveConnFlag) { - ui.PrintBanner(version) - } return cliCtx.App.Start(ctx) }, @@ -358,7 +355,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, cliCtx.Client) + pgxCLI, err := app.New(cliCtx.config, cliCtx.Printer, cliCtx.Logger.Logger, completer, cliCtx.Client, version) if err != nil { cliCtx.Logger.Error("Failed to initialize app", "error", err) return err diff --git a/internal/ui/banner.go b/internal/ui/banner.go deleted file mode 100644 index 337d3a3..0000000 --- a/internal/ui/banner.go +++ /dev/null @@ -1,60 +0,0 @@ -package ui - -import ( - _ "embed" - "fmt" - "os" - "strings" - - "github.com/muesli/termenv" -) - -//go:embed ascii.txt -var asciiArt string - -func orcaStr(out *termenv.Output) string { - oceanBlue := out.Color("#4E7080") - steel := out.Color("#A8BDC8") - - // Strip leading blank lines and trailing spaces per line. - lines := strings.Split(asciiArt, "\n") - for len(lines) > 0 && strings.TrimSpace(lines[0]) == "" { - lines = lines[1:] - } - for len(lines) > 0 && strings.TrimSpace(lines[len(lines)-1]) == "" { - lines = lines[:len(lines)-1] - } - for i, line := range lines { - lines[i] = strings.TrimRight(line, " ") - } - - var sb strings.Builder - for _, r := range strings.Join(lines, "\n") { - switch r { - case ':', ';', '.', ',', '-': - sb.WriteString(out.String(string(r)).Foreground(oceanBlue).String()) - case '▆', '▀': - sb.WriteString(out.String(string(r)).Foreground(steel).String()) - default: - sb.WriteRune(r) - } - } - return sb.String() -} - -// orcaView returns the colored orca art for use in TUI layouts. -func orcaView() string { - return orcaStr(termenv.NewOutput(os.Stdout)) -} - -// PrintBanner prints the colored ASCII art banner and a welcome line. -func PrintBanner(version string) { - out := termenv.NewOutput(os.Stdout) - green := out.Color("#02BF87") - - fmt.Print(orcaStr(out)) - fmt.Printf("\n %s %s\n\n", - out.String("pgxcli v"+version).Foreground(green).Bold().String(), - out.String("\\q to quit").Foreground(out.Color("240")).String(), - ) -}