Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 16 additions & 70 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -1484,8 +1484,8 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.Err != nil {
// Connection failed - clear pending password (don't save wrong password)
a.pendingPasswordSave = nil
a.ShowError("Connection Failed", fmt.Sprintf("Could not connect to %s:%d\n\nError: %v",
msg.Config.Host, msg.Config.Port, msg.Err))
a.ShowError("Connection Failed", fmt.Sprintf("Could not connect to %s\n\nError: %v",
msg.Config.DisplayTarget(), msg.Err))
return a, nil
}

Expand Down Expand Up @@ -2099,11 +2099,7 @@ func (a *App) renderNormalView() string {
if a.state.ActiveConnection != nil {
// Build connection string with elegant formatting
conn := a.state.ActiveConnection
connStr := fmt.Sprintf("%s@%s:%d/%s",
conn.Config.User,
conn.Config.Host,
conn.Config.Port,
conn.Config.Database)
connStr := conn.Config.ConnectionLabel()

connStatus = " " + styles.connGreen.Render("") + " " + styles.connText.Render(connStr)
} else {
Expand Down Expand Up @@ -3160,17 +3156,7 @@ func (a *App) connectToHistoryEntry(entry models.ConnectionHistoryEntry) (tea.Mo

// connectToDiscoveredInstance connects using a discovered instance
func (a *App) connectToDiscoveredInstance(instance models.DiscoveredInstance) (tea.Model, tea.Cmd) {
// Create connection config from discovered instance
config := models.ConnectionConfig{
Host: instance.Host,
Port: instance.Port,
Database: "postgres", // Default database
User: os.Getenv("USER"), // Current user
Password: "", // No password for now
SSLMode: "prefer",
}

return a.performConnection(config)
return a.performConnection(discovery.BuildConnectionConfig(instance))
}

// performConnection starts an async connection attempt
Expand Down Expand Up @@ -3325,56 +3311,21 @@ func (a *App) handleConnectionDialog(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return a, nil
}
return a.performConnection(config)
} else {
var config models.ConnectionConfig

// Check if browsing history or discovered instances
if a.connectionDialog.InHistorySection {
// Get selected history entry
historyEntry := a.connectionDialog.GetSelectedHistory()
if historyEntry == nil {
// No history entry selected
return a, nil
}

// Convert history entry to connection config WITH password from keyring
if a.connectionHistory != nil {
result := a.connectionHistory.GetConnectionConfigWithPassword(historyEntry)
config = result.Config

// If password is missing, show password dialog
if result.PasswordMissing {
entryCopy := *historyEntry
a.pendingConnectionInfo = &entryCopy
a.passwordDialog.SetConnectionInfo(historyEntry.Host, historyEntry.Port, historyEntry.Database, historyEntry.User)
a.showPasswordDialog = true
a.showConnectionDialog = false
return a, a.passwordDialog.Init()
}
} else {
config = historyEntry.ToConnectionConfig()
}
} else {
// Get selected discovered instance
instance := a.connectionDialog.GetSelectedInstance()
if instance == nil {
// No instance selected
return a, nil
}
}

// Create connection config from discovered instance
config = models.ConnectionConfig{
Host: instance.Host,
Port: instance.Port,
Database: "postgres",
User: os.Getenv("USER"),
Password: "",
SSLMode: "prefer",
}
if a.connectionDialog.InHistorySection {
historyEntry := a.connectionDialog.GetSelectedHistory()
if historyEntry == nil {
return a, nil
}
return a.connectToHistoryEntry(*historyEntry)
}

return a.performConnection(config)
instance := a.connectionDialog.GetSelectedInstance()
if instance == nil {
return a, nil
}
return a.connectToDiscoveredInstance(*instance)

default:
// In manual mode, delegate to textinput for cursor and text handling
Expand Down Expand Up @@ -3653,12 +3604,7 @@ func (a *App) renderConnectingOverlay() string {
hostStyle := lipgloss.NewStyle().Foreground(a.theme.Foreground)
hintStyle := lipgloss.NewStyle().Foreground(a.theme.Metadata)

hostInfo := fmt.Sprintf("%s@%s:%d/%s",
a.connectingConfig.User,
a.connectingConfig.Host,
a.connectingConfig.Port,
a.connectingConfig.Database,
)
hostInfo := a.connectingConfig.ConnectionLabel()

// Build each line separately
line1 := a.executeSpinner.View() + " " + loadingStyle.Render("Connecting...") + " " + elapsedStyle.Render(elapsedStr)
Expand Down
4 changes: 2 additions & 2 deletions internal/app/delegates/connection.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,8 @@ func (d *ConnectionDelegate) handleConnectionResult(msg messages.ConnectionResul
if msg.Err != nil {
// Connection failed - clear pending password (don't save wrong password)
app.ClearPendingPasswordSave()
app.ShowError("Connection Failed", fmt.Sprintf("Could not connect to %s:%d\n\nError: %v",
msg.Config.Host, msg.Config.Port, msg.Err))
app.ShowError("Connection Failed", fmt.Sprintf("Could not connect to %s\n\nError: %v",
msg.Config.DisplayTarget(), msg.Err))
return true, nil
}

Expand Down
2 changes: 1 addition & 1 deletion internal/connection_history/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ func (m *Manager) Add(config models.ConnectionConfig) (*AddResult, error) {
// Create new entry
name := config.Name
if name == "" {
name = fmt.Sprintf("%s@%s:%d/%s", config.User, config.Host, config.Port, config.Database)
name = config.ConnectionLabel()
}

entry := models.ConnectionHistoryEntry{
Expand Down
3 changes: 2 additions & 1 deletion internal/connection_history/password_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"runtime"

"github.com/99designs/keyring"
"github.com/pgplex/pgtui/internal/models"
)

const serviceName = "pgtui"
Expand Down Expand Up @@ -113,7 +114,7 @@ func (ps *PasswordStore) Save(host string, port int, database, user, password st
err := ps.ring.Set(keyring.Item{
Key: key,
Data: []byte(password),
Label: fmt.Sprintf("pgtui: %s@%s:%d/%s", user, host, port, database),
Label: "pgtui: " + (models.ConnectionConfig{Host: host, Port: port, Database: database, User: user}).ConnectionLabel(),
Description: "PostgreSQL connection password for pgtui",
})
if err != nil {
Expand Down
2 changes: 1 addition & 1 deletion internal/db/connection/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,5 +179,5 @@ func generateConnectionID(config models.ConnectionConfig) string {
if config.Name != "" {
return config.Name
}
return fmt.Sprintf("%s@%s:%d/%s", config.User, config.Host, config.Port, config.Database)
return config.ConnectionLabel()
}
87 changes: 87 additions & 0 deletions internal/db/discovery/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package discovery

import (
"os"
"strings"

"github.com/pgplex/pgtui/internal/models"
)

// BuildConnectionConfig turns a discovered instance into a connection config.
// The returned config intentionally omits the password for environment and
// .pgpass sources: libpq (pgx) reads PGPASSWORD and ~/.pgpass itself, and
// leaving the password empty prevents it from being persisted to the keyring
// (those secrets already have their own source).
func BuildConnectionConfig(instance models.DiscoveredInstance) models.ConnectionConfig {
switch instance.Source {
case models.SourceEnvironment:
if envConfig := GetEnvironmentConfig(); envConfig != nil {
config := *envConfig
config.Name = "" // avoid leaking the generic "Environment" label into connection ID/history
config.Password = ""
return config
}
case models.SourcePgPass:
if pgpassConfig := buildPgPassConfig(instance.Host, instance.Port); pgpassConfig != nil {
return *pgpassConfig
}
}

return buildDefaultConfig(instance)
}

// buildPgPassConfig maps a .pgpass entry to connection fields. Password is left
// empty: libpq reads ~/.pgpass itself, and omitting it here keeps secrets out of
// the keyring (same rationale as BuildConnectionConfig for env/.pgpass).
func buildPgPassConfig(host string, port int) *models.ConnectionConfig {
entries, err := ParsePgPass()
if err != nil {
return nil
}

for _, entry := range entries {
if entry.Host != host || entry.Port != port {
continue
}

user := entry.User
if user == "" || user == "*" {
user = defaultUser()
}

database := entry.Database
if database == "" || database == "*" {
database = user
}

return &models.ConnectionConfig{
Host: host,
Port: port,
Database: database,
User: user,
SSLMode: "prefer",
}
}

return nil
}

func buildDefaultConfig(instance models.DiscoveredInstance) models.ConnectionConfig {
return models.ConnectionConfig{
Host: instance.Host,
Port: instance.Port,
Database: "postgres",
User: defaultUser(),
SSLMode: "prefer",
}
}

func defaultUser() string {
for _, key := range []string{"PGUSER", "USER", "USERNAME"} {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
return value
}
}

return "postgres"
}
65 changes: 53 additions & 12 deletions internal/db/discovery/discovery.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,34 +29,47 @@ func (d *Discoverer) DiscoverAll(ctx context.Context) []models.DiscoveredInstanc
instances = append(instances, *envInstance)
}

// 2. Scan localhost ports
localInstances := d.scanner.ScanLocalhost(ctx)
instances = append(instances, localInstances...)
// 2. Scan common Unix socket directories
instances = append(instances, d.scanner.ScanUnixSockets(ctx)...)

// 3. Parse .pgpass
pgpassInstances := GetDiscoveredInstances()
instances = append(instances, pgpassInstances...)
// 3. Scan localhost ports
instances = append(instances, d.scanner.ScanLocalhost(ctx)...)

// 4. Parse .pgpass
instances = append(instances, GetDiscoveredInstances()...)

// Deduplicate
instances = deduplicateInstances(instances)

// Sort by source priority
sort.Slice(instances, func(i, j int) bool {
return instances[i].Source < instances[j].Source
})
sortDiscoveredInstances(instances)

return instances
}

// deduplicateInstances removes duplicate host:port combinations
func sortDiscoveredInstances(instances []models.DiscoveredInstance) {
sort.Slice(instances, func(i, j int) bool {
if instances[i].Source != instances[j].Source {
return discoverySourcePriority(instances[i].Source) < discoverySourcePriority(instances[j].Source)
}

if instances[i].Host != instances[j].Host {
return instances[i].Host < instances[j].Host
}

return instances[i].Port < instances[j].Port
})
Comment thread
XYenon marked this conversation as resolved.
}

// deduplicateInstances removes duplicate connection targets.
func deduplicateInstances(instances []models.DiscoveredInstance) []models.DiscoveredInstance {
seen := make(map[string]models.DiscoveredInstance)

for _, instance := range instances {
Comment thread
XYenon marked this conversation as resolved.
key := instance.Host + ":" + strconv.Itoa(instance.Port)
key := instanceKey(instance)

// Keep the one with higher priority source
if existing, exists := seen[key]; !exists || instance.Source < existing.Source {
if existing, exists := seen[key]; !exists || discoverySourcePriority(instance.Source) < discoverySourcePriority(existing.Source) {
seen[key] = instance
}
}
Expand All @@ -68,3 +81,31 @@ func deduplicateInstances(instances []models.DiscoveredInstance) []models.Discov

return result
}

func instanceKey(instance models.DiscoveredInstance) string {
host := instance.Host
if instance.UsesUnixSocket() {
host = socketDirKey(host)
}

return host + ":" + strconv.Itoa(instance.Port)
}

func discoverySourcePriority(source models.DiscoverySource) int {
switch source {
case models.SourceEnvironment:
return 0
case models.SourcePgPass:
return 1
case models.SourcePgService:
return 2
case models.SourceConfig:
return 3
case models.SourceUnixSocket:
return 4
case models.SourcePortScan:
return 5
default:
return 100
}
}
Loading
Loading