diff --git a/Makefile b/Makefile deleted file mode 100644 index de111c9..0000000 --- a/Makefile +++ /dev/null @@ -1,8 +0,0 @@ -run: - go run . - -tidy: - go mod tidy - -lint: - golangci-lint run diff --git a/app.go b/app.go new file mode 100644 index 0000000..9501072 --- /dev/null +++ b/app.go @@ -0,0 +1,602 @@ +package main + +import ( + "log" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +type App struct { + *tview.Application + conn Connection + pages *tview.Pages + content *tview.Pages + hotkeys *tview.Pages + connections *DisplayTable + databaseTable *DbTable + connectionsView *ContentBox +} + +func NewApp() App { + tview.Borders.HorizontalFocus = tview.BoxDrawingsLightHorizontal + tview.Borders.VerticalFocus = tview.BoxDrawingsLightVertical + tview.Borders.TopLeftFocus = tview.BoxDrawingsLightDownAndRight + tview.Borders.TopRightFocus = tview.BoxDrawingsLightDownAndLeft + tview.Borders.BottomLeftFocus = tview.BoxDrawingsLightUpAndRight + tview.Borders.BottomRightFocus = tview.BoxDrawingsLightUpAndLeft + + app := tview.NewApplication() + + connectionHotkeys := GetConnectionHotkeys() + databaseHotkeys := GetDatabaseHotkeys() + + hotkeys := tview.NewPages(). + AddPage("databaseHotkeys", databaseHotkeys, true, false). + AddAndSwitchToPage("connectionHotkeys", connectionHotkeys, true) + + header := headerPanel(NewConnection(), hotkeys) + + content := tview.NewPages() + mainView := newLayout(tview.FlexRow, header, content) + + pages := tview.NewPages(). + AddAndSwitchToPage(MAIN_PAGE, mainView, true) + + app.SetRoot(pages, true).SetFocus(content) + + ctx := App{app, NewConnection(), pages, content, hotkeys, &DisplayTable{}, nil, nil} + ctx.setInputHandler() + ctx.refreshConnections() + + return ctx +} + +func (app *App) refreshConnections() { + connectionsTable := newConnectionsTable(CONNECTION_TABLE_HEADERS) + connectionsView := newContentBox("Connections", connectionsTable) + + app.content.AddPage(SAVED_CONNECTIONS, connectionsView, true, true) + app.connections = connectionsTable + app.connectionsView = connectionsView +} + +func (app *App) setInputHandler() { + app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + pageName, _ := app.pages.GetFrontPage() + currentHotkeys, _ := app.hotkeys.GetFrontPage() + contentView, _ := app.content.GetFrontPage() + + if pageName == NEW_CONNECTION_FORM || app.connectionsView.searchBar != nil { + if event.Key() == tcell.KeyEscape { + app.connectionsView.toggleSearchBar(nil) + app.connections.getConnections() + } + + return event + } + + switch currentHotkeys { + case "connectionHotkeys": + { + switch event.Rune() { + case 'q': + app.Stop() + return nil + case 'n': + app.showNewConnectionForm(NewConnection()) + return nil + case 'e': + connection := app.connections.getConnection() + if connection != nil { + app.showNewConnectionForm(*connection) + } + return nil + case 'd': + connection := app.connections.getConnection() + if connection != nil { + app.showDeleteConfirmation(*connection) + } + return nil + case 'o': + connection := app.connections.getConnection() + if connection != nil { + app.openConnection(*connection) + } + return nil + case 't': + connection := app.connections.getConnection() + if connection != nil { + testResult := connection.TestConnection() + closeModal := func() { + app.pages.RemovePage(CONNECTION_TEST) + } + + switch testResult { + case PASSED: + app.showInfoModal(CONNECTION_TEST, "Connection successful!", closeModal) + case FAILED: + app.showInfoModal(CONNECTION_TEST, "Connection failed. Please check your settings.", closeModal) + } + } + return nil + case 'r': + app.refreshConnections() + return nil + case '/': + app.showSearchInput() + return nil + case 's': + app.sortConnectionsByName() + return nil + case '?': + app.showHelpView() + return nil + } + + // Handle special keys + switch event.Key() { + case tcell.KeyESC: + if contentView == DATABASE_VIEW { + app.returnToConnectionsView() + + return nil + } + app.closeModals() + + return nil + case tcell.KeyEnter: + if contentView != DATABASE_VIEW && pageName != CONFIRM_DELETE && pageName != CONNECTION_TEST { + connection := app.connections.getConnection() + if connection != nil { + app.openConnection(*connection) + } + } + + return event + } + } + case "databaseHotkeys": + { + // Handle database view hotkeys + switch event.Rune() { + case 'q': + app.Stop() + return nil + case 'b': + app.returnToConnectionsView() + return nil + case 'r': + app.refreshCurrentConnection() + return nil + case 'e': + app.showQueryEditor() + return nil + case 'x': + app.showExportOptions() + return nil + case 'f': + app.showFilterInput() + return nil + case 'c': + app.copySelectedRow() + return nil + case 'y': + app.copySelectedCell() + return nil + case 'n': + app.goToNextPage() + return nil + case 'p': + app.goToPreviousPage() + return nil + case 'v': + app.toggleViewMode() + return nil + case '?': + app.showHelpView() + return nil + } + + // Handle special keys + switch event.Key() { + case tcell.KeyESC: + app.returnToConnectionsView() + return nil + case tcell.KeyEnter: + if contentView == DATABASE_VIEW && app.databaseTable.db.schema != "" { + app.databaseTable.showTables() + } else if contentView == DATABASE_VIEW && app.databaseTable.db.schema == "" { + app.databaseTable.showSchema() + } else { + panic(contentView) + } + + } + } + case "helpHotkeys": + // Handle help view hotkeys + switch event.Rune() { + case 'q': + app.Stop() + return nil + case 'b': + app.closeHelpView() + return nil + } + + // Handle special keys + switch event.Key() { + case tcell.KeyESC: + app.closeHelpView() + return nil + } + + case "queryHotkeys": + // Handle query editor hotkeys + switch event.Key() { + case tcell.KeyESC: + app.closeQueryEditor() + return nil + case tcell.KeyCtrlE: + app.executeQuery() + return nil + case tcell.KeyCtrlS: + app.saveQuery() + return nil + case tcell.KeyCtrlO: + app.loadQuery() + return nil + case tcell.KeyCtrlL: + app.clearQuery() + return nil + case tcell.KeyCtrlH: + app.showQueryHistory() + return nil + } + + case "exportHotkeys": + // Handle export options hotkeys + switch event.Rune() { + case 'c': + app.exportAsCSV() + return nil + case 'j': + app.exportAsJSON() + return nil + case 's': + app.exportAsSQL() + return nil + } + + // Handle special keys + switch event.Key() { + case tcell.KeyESC: + app.closeExportOptions() + return nil + } + + return event + } + + return event + }) +} + +// Helper methods to clean up the input handler +func (app *App) showNewConnectionForm(connection Connection) { + addConnectionForm := newConnectionForm(app, connection, func() { + app.pages.RemovePage(NEW_CONNECTION_FORM) + app.SetFocus(app.content) + }) + app.pages.AddPage(NEW_CONNECTION_FORM, addConnectionForm, true, true) +} + +func (app *App) showDeleteConfirmation(connection Connection) { + confirmDeleteModal := tview.NewModal(). + SetText("Are you sure you want to delete: " + connection.Name + "?"). + AddButtons([]string{"Cancel", "Confirm"}). + SetDoneFunc(func(buttonIndex int, buttonLabel string) { + if buttonLabel == "Confirm" { + err := DeleteConnection(connection.Name) + if err != nil { + log.Fatal("Could not delete connection", err) + } + + app.refreshConnections() + } + app.pages.RemovePage(CONFIRM_DELETE) + app.SetFocus(app.content) + }) + app.pages.AddPage(CONFIRM_DELETE, confirmDeleteModal, true, true) +} + +func (app *App) showInfoModal(page, body string, escapeFunc func()) { + confirmDeleteModal := tview.NewModal(). + SetText(body). + AddButtons([]string{"Ok"}). + SetDoneFunc(func(buttonIndex int, buttonLabel string) { + escapeFunc() + }) + + app.pages.AddPage(page, confirmDeleteModal, true, true) +} + +func (app *App) refreshCurrentConnection() { + connection := app.connections.getConnection() + if connection != nil { + app.openConnection(*connection) + } +} + +func (app *App) returnToConnectionsView() { + // Switch back to connection hotkeys + app.hotkeys.SwitchToPage("connectionHotkeys") + + newHeader := newLayout(tview.FlexRow, headerPanel(NewConnection(), app.hotkeys), app.content) + app.pages.AddPage(SAVED_CONNECTIONS, newHeader, true, true) + app.content.RemovePage(DATABASE_VIEW) + app.SetFocus(app.content) +} + +func (app *App) closeModals() { + app.pages.RemovePage(NEW_CONNECTION_FORM) + app.pages.RemovePage(CONFIRM_DELETE) + app.SetFocus(app.content) +} + +func (app *App) openConnection(connection Connection) { + db := NewOpenDatabase(connection) + dbTable := newDbTable(db) + dbContent := newContentBox(db.openTable.name, dbTable) + + app.databaseTable = dbTable + + app.hotkeys.SwitchToPage("databaseHotkeys") + + layoutHeader := headerPanel(connection, app.hotkeys) + newHeader := newLayout(tview.FlexRow, layoutHeader, app.content) + app.pages.AddPage(SAVED_CONNECTIONS, newHeader, true, true) + + app.content.AddAndSwitchToPage(DATABASE_VIEW, dbContent, true) + app.SetFocus(app.content) +} + +func (app *App) showSearchInput() { + app.connectionsView.toggleSearchBar(func(value string) { + app.connections.updateTable(value) + }) +} + +// Sorting functionality +func (app *App) sortConnectionsByName() { + // Implementation would sort the connections table by name + // This is a placeholder - actual implementation would depend on your data structure +} + +// Help view +func (app *App) showHelpView() { + helpText := tview.NewTextView(). + SetDynamicColors(true). + SetRegions(true). + SetWordWrap(true). + SetText(`[yellow]TermTable Help[white] + +[green]Connection View Hotkeys:[white] + New Connection - Create a new database connection + Edit Connection - Edit the selected connection + Delete Connection - Delete the selected connection + Open Connection - Open the selected connection + Test Connection - Test the selected connection + Refresh Connections - Refresh the list of connections + Search - Search for a connection + Sort by Name - Sort connections by name + Help - Show this help screen + Quit - Exit the application + +[green]Database View Hotkeys:[white] + Back to Connections - Return to the connections view + Refresh Data - Refresh the current data view + Execute Query - Open the query editor + Export Results - Export the current results + Filter Results - Filter the current results + Copy Row - Copy the selected row + Copy Cell - Copy the selected cell + Next Page - Go to the next page of results +

Previous Page - Go to the previous page of results + Toggle View Mode - Toggle between different view modes + Help - Show this help screen + Quit - Exit the application + +[green]Navigation:[white] +Use arrow keys to navigate tables and lists. +Press Enter to select or open an item. +Press Esc to go back or close a modal.`) + + helpView := tview.NewFrame(helpText). + SetBorders(0, 0, 0, 0, 0, 0). + AddText("Help", true, tview.AlignCenter, tcell.ColorYellow). + AddText("Press 'b' to go back", false, tview.AlignCenter, tcell.ColorWhite) + + // Create help hotkeys + helpHotkeys := GetHelpHotkeys() + app.hotkeys.AddAndSwitchToPage("helpHotkeys", helpHotkeys, true) + + // Save current view to return to it later + app.pages.AddPage("helpView", helpView, true, true) + app.SetFocus(helpView) +} + +func (app *App) closeHelpView() { + app.pages.RemovePage("helpView") + + // Switch back to previous hotkeys + contentView, _ := app.content.GetFrontPage() + if contentView == DATABASE_VIEW { + app.hotkeys.SwitchToPage("databaseHotkeys") + } else { + app.hotkeys.SwitchToPage("connectionHotkeys") + } + + app.SetFocus(app.content) +} + +// Query editor functionality +func (app *App) showQueryEditor() { + queryEditor := tview.NewTextArea(). + SetPlaceholder("Enter SQL query here..."). + SetWordWrap(true) + + queryFrame := tview.NewFrame(queryEditor). + SetBorders(0, 0, 0, 0, 0, 0). + AddText("SQL Query Editor", true, tview.AlignCenter, tcell.ColorYellow). + AddText("Ctrl+E: Execute | Ctrl+S: Save | Ctrl+O: Load | Ctrl+L: Clear | Esc: Exit", false, tview.AlignCenter, tcell.ColorWhite) + + // Create query hotkeys + queryHotkeys := GetQueryHotkeys() + app.hotkeys.AddAndSwitchToPage("queryHotkeys", queryHotkeys, true) + + app.pages.AddPage("queryEditor", queryFrame, true, true) + app.SetFocus(queryEditor) +} + +func (app *App) closeQueryEditor() { + app.pages.RemovePage("queryEditor") + app.hotkeys.SwitchToPage("databaseHotkeys") + app.SetFocus(app.content) +} + +func (app *App) executeQuery() { + // Implementation would execute the query in the editor + // This is a placeholder +} + +func (app *App) saveQuery() { + // Implementation would save the current query + // This is a placeholder +} + +func (app *App) loadQuery() { + // Implementation would load a saved query + // This is a placeholder +} + +func (app *App) clearQuery() { + // Implementation would clear the query editor + // This is a placeholder +} + +func (app *App) showQueryHistory() { + // Implementation would show query history + // This is a placeholder +} + +// Export functionality +func (app *App) showExportOptions() { + exportModal := tview.NewModal(). + SetText("Export Options"). + AddButtons([]string{"CSV", "JSON", "SQL", "Cancel"}). + SetDoneFunc(func(buttonIndex int, buttonLabel string) { + switch buttonLabel { + case "CSV": + app.exportAsCSV() + case "JSON": + app.exportAsJSON() + case "SQL": + app.exportAsSQL() + } + app.pages.RemovePage("exportOptions") + app.SetFocus(app.content) + }) + + // Create export hotkeys + exportHotkeys := GetExportHotkeys() + app.hotkeys.AddAndSwitchToPage("exportHotkeys", exportHotkeys, true) + + app.pages.AddPage("exportOptions", exportModal, true, true) +} + +func (app *App) closeExportOptions() { + app.pages.RemovePage("exportOptions") + app.hotkeys.SwitchToPage("databaseHotkeys") + app.SetFocus(app.content) +} + +func (app *App) exportAsCSV() { + // Implementation would export data as CSV + // This is a placeholder +} + +func (app *App) exportAsJSON() { + // Implementation would export data as JSON + // This is a placeholder +} + +func (app *App) exportAsSQL() { + // Implementation would export data as SQL + // This is a placeholder +} + +// Filter functionality +func (app *App) showFilterInput() { + var inputField *tview.InputField + inputField = tview.NewInputField(). + SetLabel("Filter: "). + SetFieldWidth(30). + SetDoneFunc(func(key tcell.Key) { + switch key { + case tcell.KeyEnter: + filterTerm := inputField.GetText() + app.filterResults(filterTerm) + app.pages.RemovePage("filterInput") + app.SetFocus(app.content) + case tcell.KeyEscape: + app.pages.RemovePage("filterInput") + app.SetFocus(app.content) + } + }) + + modal := tview.NewFlex(). + AddItem(nil, 0, 1, false). + AddItem(tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(nil, 0, 1, false). + AddItem(inputField, 3, 1, true). + AddItem(nil, 0, 1, false), 40, 1, true). + AddItem(nil, 0, 1, false) + + app.pages.AddPage("filterInput", modal, true, true) + app.SetFocus(inputField) +} + +func (app *App) filterResults(term string) { + // Implementation would filter the results based on the filter term + // This is a placeholder +} + +// Copy functionality +func (app *App) copySelectedRow() { + // Implementation would copy the selected row to clipboard + // This is a placeholder +} + +func (app *App) copySelectedCell() { + // Implementation would copy the selected cell to clipboard + // This is a placeholder +} + +// Pagination functionality +func (app *App) goToNextPage() { + // Implementation would go to the next page of results + // This is a placeholder +} + +func (app *App) goToPreviousPage() { + // Implementation would go to the previous page of results + // This is a placeholder +} + +// View mode functionality +func (app *App) toggleViewMode() { + // Implementation would toggle between different view modes + // This is a placeholder +} diff --git a/components.go b/components.go new file mode 100644 index 0000000..8981907 --- /dev/null +++ b/components.go @@ -0,0 +1,113 @@ +package main + +import ( + "strconv" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +func currentConnectionInfo(conn Connection) *tview.List { + list := tview.NewList(). + ShowSecondaryText(false). + SetSelectedFocusOnly(true). + AddItem("Name: "+conn.Name, "", 0, nil). + AddItem("Host: "+conn.Host, "", 0, nil). + AddItem("PORT: "+conn.Port, "", 0, nil). + AddItem("USER: "+conn.User, "", 0, nil). + AddItem("Database: "+conn.Database, "", 0, nil) + + return list +} + +func headerPanel(conn Connection, hotkeys *tview.Pages) *tview.Flex { + connection := currentConnectionInfo(conn) + + appName := tview.NewTextView().SetText(APP_NAME).SetTextAlign(tview.AlignRight) + + headerView := tview.NewFlex(). + AddItem(connection, 0, 1, false). + AddItem(hotkeys, 0, 1, false). + AddItem(appName, 0, 1, false) + headerView.SetBorderPadding(0, 0, 1, 1) + + return headerView +} + +func newConnectionForm(app *App, conn Connection, escapeFunc func()) *tview.Flex { + form := tview.NewForm(). + AddInputField("Name", conn.Name, 26, nil, func(text string) { conn.Name = text }). + AddInputField("Host", conn.Host, 26, nil, func(text string) { conn.Host = text }). + AddInputField("Port", conn.Port, 26, func(textToCheck string, lastChar rune) bool { + _, err := strconv.Atoi(textToCheck) + + return err == nil + }, func(text string) { conn.Port = text }). + AddInputField("User", conn.User, 26, nil, func(text string) { conn.User = text }). + AddPasswordField("Password", conn.Password, 26, '*', func(text string) { conn.Password = text }). + AddInputField("Database", conn.Database, 26, nil, func(text string) { conn.Database = text }). + AddButton("Save", func() { + testResult := conn.TestConnection() + if testResult == FAILED { + app.showInfoModal(CONNECTION_TEST, "Could not save connection because connection could not be established.", func() { + app.pages.RemovePage(CONNECTION_TEST) + }) + return + } + + SaveConnectionInKeyring(conn) + + escapeFunc() + + app.refreshConnections() + }). + AddButton("Test", func() { + testResult := conn.TestConnection() + closeModal := func() { + app.pages.RemovePage(CONNECTION_TEST) + } + + switch testResult { + case PASSED: + app.showInfoModal(CONNECTION_TEST, "Connection successful!", closeModal) + case FAILED: + app.showInfoModal(CONNECTION_TEST, "Connection failed. Please check your settings.", closeModal) + } + }). + AddButton("Connect", func() { + testResult := conn.TestConnection() + if testResult == FAILED { + app.showInfoModal(CONNECTION_TEST, "Could not connect because connection could not be established", func() { + app.pages.RemovePage(CONNECTION_TEST) + }) + return + } + + escapeFunc() + + app.refreshConnections() + + app.openConnection(conn) + }) + + form.SetBorder(true) + form.SetButtonsAlign(tview.AlignRight) + + modal := tview.NewFlex(). + AddItem(nil, 0, 3, false). + AddItem(tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(nil, 0, 1, false). + AddItem(form, 0, 1, true). + AddItem(nil, 0, 1, false), 0, 2, true). + AddItem(nil, 0, 3, false) + + modal.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyESC { + escapeFunc() + } + + return event + }) + + return modal +} diff --git a/constants.go b/constants.go new file mode 100644 index 0000000..a2deb3e --- /dev/null +++ b/constants.go @@ -0,0 +1,46 @@ +package main + +// CurrentView represents the current view state of the application +type CurrentView string + +const APP_NAME = `_________________ +\______ \______ \______ + | | \ / / ___/ + | ` + "`" + ` \/ /\___ \ +/_______ /____//____ > + \/ \/ +` + +const ( + DEFAULT CurrentView = "DEFAULT" + NEW_CONNECTION CurrentView = "NEW_CONNECTION" + EDIT_CONNECTION CurrentView = "EDIT_CONNECTION" + JOIN_EXISTING CurrentView = "JOIN_EXISTING" + // DATABASE_VIEW CurrentView = "DATABASE_VIEW". +) + +// Primary ansi colours. +const ( + WHITE = "15" + RED = "1" + GREEN = "2" + YELLOW = "3" + BLUE = "4" + MAGENTA = "5" + GREY = "240" + LIGHT_GREY = "244" +) + +type PageName string + +const ( + MAIN_PAGE = "main" + NEW_CONNECTION_FORM = "newConnection" + SAVED_CONNECTIONS = "savedConnections" + DATABASE_VIEW = "databaseView" + SCHEMA_VIEW = "schemaView" + CONNECTION_TEST = "ConntectionTest" + CONFIRM_DELETE = "ConfirmDelete" +) + +var CONNECTION_TABLE_HEADERS = []string{"ID", "NAME", "HOST", "PORT", "USER", "DATABASE"} diff --git a/content_box.go b/content_box.go new file mode 100644 index 0000000..23e7f64 --- /dev/null +++ b/content_box.go @@ -0,0 +1,75 @@ +package main + +import ( + "fmt" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +type ContentBox struct { + *tview.Flex + content tview.Primitive + box *tview.Box + searchBar *tview.InputField +} + +func newContentBox(title string, content tview.Primitive) *ContentBox { + box := tview.NewBox().SetBorder(true).SetTitle(fmt.Sprintf(" %s ", title)) + + container := tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(box, 0, 1, true) + + return &ContentBox{container, content, box, nil} +} + +func (b *ContentBox) toggleSearchBar(searchFunc func(value string)) { + if b.searchBar != nil { + searchBar := b.GetItem(0) + b.searchBar = nil + b.RemoveItem(searchBar) + return + } + + // Add the search bar re-adding the content so ordering is preserved + searchBar := newSearchBar(searchFunc) + b.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + searchBar.InputHandler()(event, func(p tview.Primitive) {}) + return event + }) + b.searchBar = searchBar + content := b.GetItem(0) + b.RemoveItem(content) + b.AddItem(searchBar, 3, 0, true) + b.AddItem(content, 0, 1, false) +} + +func (b *ContentBox) Draw(screen tcell.Screen) { + b.Flex.Draw(screen) + + x, y, w, h := b.box.GetInnerRect() + + b.content.SetRect(x, y, w, h) + b.content.Draw(screen) +} + +func (b *ContentBox) InputHandler() func(event *tcell.EventKey, setFocus func(p tview.Primitive)) { + return b.WrapInputHandler(func(event *tcell.EventKey, setFocus func(p tview.Primitive)) { + if b.searchBar != nil { + return + } + + b.content.InputHandler()(event, setFocus) + }) +} + +func newSearchBar(apply func(value string)) *tview.InputField { + searchBar := tview.NewInputField(). + SetFieldWidth(0). + SetChangedFunc(apply) + searchBar.SetBorder(true) + searchBar.SetFieldBackgroundColor(tcell.ColorNone) + + return searchBar +} diff --git a/docker-compose.yml b/data/compose.yml similarity index 80% rename from docker-compose.yml rename to data/compose.yml index c4f0aef..3a00917 100644 --- a/docker-compose.yml +++ b/data/compose.yml @@ -1,5 +1,7 @@ +name: termtable + services: - pgdb: + pg: image: postgres:latest restart: always environment: @@ -10,7 +12,7 @@ services: - "5432:5432" volumes: - termtabledata:/var/lib/postgresql/data - - ./data/postgres:/docker-entrypoint-initdb.d + - ./postgres:/docker-entrypoint-initdb.d volumes: termtabledata: diff --git a/db.go b/db.go index ab012dd..493238b 100644 --- a/db.go +++ b/db.go @@ -3,7 +3,9 @@ package main import ( "context" "fmt" + "log" + "github.com/google/uuid" "github.com/jackc/pgx/v5" ) @@ -15,56 +17,102 @@ const ( ) type Connection struct { + ID uuid.UUID Host string Port string User string - Pass string + Password string Database string Name string status ConnectionStatus } +func NewConnection() Connection { + ID, err := uuid.NewV7() + if err != nil { + log.Fatal("Could not create id for connection", err) + } + return Connection{ID: ID} +} + +func (c Connection) Row() []string { + return []string{c.ID.String(), c.Name, c.Host, c.Port, c.User, c.Database} +} + func (params Connection) ConnectionString() string { // urlExample := "postgres://username:password@localhost:5432/database_name" - return fmt.Sprintf("postgres://%s:%s@%s:%s/%s", params.User, params.Pass, params.Host, params.Port, params.Database) + return fmt.Sprintf("postgres://%s:%s@%s:%s/%s", params.User, params.Password, params.Host, params.Port, params.Database) } func (params *Connection) TestConnection() TestStatus { connectionString := params.ConnectionString() conn, err := pgx.Connect(context.Background(), connectionString) - if err != nil { params.status = DISCONNECTED + return FAILED } conn.Close(context.Background()) params.status = CONNECTED + return PASSED } -func (parmas Connection) GetTableNames() []string { - connectionString := parmas.ConnectionString() +func (params Connection) GetSchemas() []string { + connectionString := params.ConnectionString() conn, err := pgx.Connect(context.Background(), connectionString) + if err != nil { + return nil + } + rows, err := conn.Query(context.Background(), "SELECT schema_name FROM information_schema.schemata") if err != nil { return nil } - rows, err := conn.Query(context.Background(), "SELECT table_name FROM information_schema.tables WHERE table_schema='public'") + var schemaNames []string + + for rows.Next() { + var schemaName string + + err = rows.Scan(&schemaName) + if err != nil { + return nil + } + + schemaNames = append(schemaNames, schemaName) + } + + conn.Close(context.Background()) + + return schemaNames +} +func (parmas Connection) GetTableNames(schema string) []string { + connectionString := parmas.ConnectionString() + conn, err := pgx.Connect(context.Background(), connectionString) if err != nil { return nil } + query := fmt.Sprintf("SELECT table_name FROM information_schema.tables WHERE table_schema = '%s'", schema) + rows, err := conn.Query(context.Background(), query) + if err != nil { + return []string{"No tables in schema"} + } + var tableNames []string + for rows.Next() { var tableName string + err = rows.Scan(&tableName) if err != nil { return nil } + tableNames = append(tableNames, tableName) } @@ -74,6 +122,7 @@ func (parmas Connection) GetTableNames() []string { } type Table struct { + name string fields []string values [][]string } @@ -81,13 +130,11 @@ type Table struct { func (params Connection) SelectAll(table string) (Table, error) { connectionString := params.ConnectionString() conn, err := pgx.Connect(context.Background(), connectionString) - if err != nil { return Table{}, err } - rows, err := conn.Query(context.Background(), fmt.Sprintf("SELECT * FROM %s", table)) - + rows, err := conn.Query(context.Background(), "SELECT * FROM "+table) if err != nil { return Table{}, err } @@ -96,6 +143,7 @@ func (params Connection) SelectAll(table string) (Table, error) { fieldDescriptions := rows.FieldDescriptions() tableData.fields = make([]string, len(fieldDescriptions)) + for i, field := range fieldDescriptions { tableData.fields[i] = field.Name } @@ -115,6 +163,8 @@ func (params Connection) SelectAll(table string) (Table, error) { tableData.values = append(tableData.values, strValues) } + tableData.name = table + conn.Close(context.Background()) return tableData, nil diff --git a/existing_connection.go b/existing_connection.go index 446ac2d..06ab7d0 100644 --- a/existing_connection.go +++ b/existing_connection.go @@ -1,95 +1 @@ package main - -import ( - "log" - - "github.com/charmbracelet/bubbles/list" - tea "github.com/charmbracelet/bubbletea" -) - -type ExistingConnectionsModel struct { - list list.Model - connections []Connection - selectedConnection *Connection - back bool -} - -func NewExistingConnectionsModel() ExistingConnectionsModel { - existingConnectionsModel := ExistingConnectionsModel{} - - connections, err := ListConnections() - - if err != nil { - return existingConnectionsModel - } - - items := []list.Item{} - - for _, conn := range connections { - items = append(items, item(conn.Name)) - } - - l := list.New(items, itemDelegate{}, defaultWidth, listHeight) - l.Title = "Choose a connection" - l.SetShowStatusBar(false) - l.SetFilteringEnabled(false) - l.Styles.Title = titleStyle - l.Styles.PaginationStyle = paginationStyle - l.Styles.HelpStyle = helpStyle - - existingConnectionsModel.list = l - existingConnectionsModel.connections = connections - - return existingConnectionsModel -} - -func (m ExistingConnectionsModel) Init() tea.Cmd { - return nil -} - -func (m ExistingConnectionsModel) Update(msg tea.Msg) (ExistingConnectionsModel, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.list.SetWidth(msg.Width) - return m, nil - - case tea.KeyMsg: - switch keypress := msg.String(); keypress { - case "q", "ctrl+c": - m.back = true - return m, nil - - case "enter": - i, ok := m.list.SelectedItem().(item) - if ok { - choice := string(i) - - for _, v := range m.connections { - if v.Name == choice { - m.selectedConnection = &v - - user, pass, err := GetConnectionFromKeyring(v.Name) - - if err != nil { - log.Fatal("Could not get user and password for connection from keyring: ", err) - } - - m.selectedConnection.User = user - m.selectedConnection.Pass = pass - - break - } - } - } - return m, nil - } - } - - var cmd tea.Cmd - m.list, cmd = m.list.Update(msg) - return m, cmd -} - -func (m ExistingConnectionsModel) View() string { - return m.list.View() -} diff --git a/go.mod b/go.mod index 0e35970..a5a9e32 100644 --- a/go.mod +++ b/go.mod @@ -1,38 +1,36 @@ module github.com/robertazzopardi/termtable -go 1.22.0 +go 1.23.4 require ( - github.com/charmbracelet/bubbles v0.18.0 - github.com/charmbracelet/bubbletea v0.25.0 - github.com/charmbracelet/lipgloss v0.10.0 + github.com/gdamore/tcell/v2 v2.7.1 + github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.5.5 + github.com/rivo/tview v0.0.0-20241227133733-17b7edb88c57 github.com/zalando/go-keyring v0.2.4 - go.etcd.io/bbolt v1.3.9 + modernc.org/sqlite v1.36.0 ) require ( github.com/alessio/shellescape v1.4.1 // indirect - github.com/atotto/clipboard v0.1.4 // indirect - github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect github.com/danieljoos/wincred v1.2.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/gdamore/encoding v1.0.0 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/mattn/go-isatty v0.0.18 // indirect - github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect - github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect - github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/reflow v0.3.0 // indirect - github.com/muesli/termenv v0.15.2 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f // indirect golang.org/x/crypto v0.17.0 // indirect - golang.org/x/sync v0.5.0 // indirect - golang.org/x/sys v0.15.0 // indirect - golang.org/x/term v0.15.0 // indirect + golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/term v0.17.0 // indirect golang.org/x/text v0.14.0 // indirect + modernc.org/libc v1.61.13 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.8.2 // indirect ) diff --git a/go.sum b/go.sum index ae880ea..eec57ec 100644 --- a/go.sum +++ b/go.sum @@ -1,24 +1,22 @@ github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0= github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30= -github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= -github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= -github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= -github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0= -github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw= -github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM= -github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg= -github.com/charmbracelet/lipgloss v0.10.0 h1:KWeXFSexGcfahHX+54URiZGkBFazf70JNMtwg/AFW3s= -github.com/charmbracelet/lipgloss v0.10.0/go.mod h1:Wig9DSfvANsxqkRsqj6x87irdy123SR4dOXlKa91ciE= -github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY= -github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= github.com/danieljoos/wincred v1.2.0 h1:ozqKHaLK0W/ii4KVbbvluM91W2H3Sh0BncbUNPS7jLE= github.com/danieljoos/wincred v1.2.0/go.mod h1:FzQLLMKBFdvu+osBrnFODiv32YGwCfx0SkRa/eYHgec= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko= +github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= +github.com/gdamore/tcell/v2 v2.7.1 h1:TiCcmpWHiAU7F0rA2I3S2Y4mmLmO9KHxJ7E1QhYzQbc= +github.com/gdamore/tcell/v2 v2.7.1/go.mod h1:dSXtXTSK0VsW1biw65DZLZ2NKr7j0qP/0J7ONmsraWg= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= @@ -27,33 +25,24 @@ github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw= github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= -github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= -github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= -github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= -github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= -github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= -github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= -github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= -github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= -github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= -github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rivo/tview v0.0.0-20241227133733-17b7edb88c57 h1:LmsF7Fk5jyEDhJk0fYIqdWNuTxSyid2W42A0L2YWjGE= +github.com/rivo/tview v0.0.0-20241227133733-17b7edb88c57/go.mod h1:02iFIz7K/A9jGCvrizLPvoqr4cEIx7q54RH5Qudkrss= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f h1:MvTmaQdww/z0Q4wrYjDSCcZ78NoftLQyHBSLW/Cx79Y= -github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -61,23 +50,81 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/zalando/go-keyring v0.2.4 h1:wi2xxTqdiwMKbM6TWwi+uJCG/Tum2UV0jqaQhCa9/68= github.com/zalando/go-keyring v0.2.4/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk= -go.etcd.io/bbolt v1.3.9 h1:8x7aARPEXiXbHmtUwAIv7eV2fQFHrLLavdiJ3uzJXoI= -go.etcd.io/bbolt v1.3.9/go.mod h1:zaO32+Ti0PK1ivdPtgMESzuzL2VPoIG1PCQNvOdo/dE= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= -golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE= -golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 h1:pVgRXcIictcr+lBQIFeiwuwtDIs4eL21OuM9nyAADmo= +golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8= +golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= -golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4= -golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.17.0 h1:mkTF7LCd6WGJNL3K1Ad7kwxNfYAW6a8a8QqtMblp/4U= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg= +golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.24.4 h1:TFkx1s6dCkQpd6dKurBNmpo+G8Zl4Sq/ztJ+2+DEsh0= +modernc.org/cc/v4 v4.24.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.23.16 h1:Z2N+kk38b7SfySC1ZkpGLN2vthNJP1+ZzGZIlH7uBxo= +modernc.org/ccgo/v4 v4.23.16/go.mod h1:nNma8goMTY7aQZQNTyN9AIoJfxav4nvTnvKThAeMDdo= +modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= +modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/gc/v2 v2.6.3 h1:aJVhcqAte49LF+mGveZ5KPlsp4tdGdAOT4sipJXADjw= +modernc.org/gc/v2 v2.6.3/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/libc v1.61.13 h1:3LRd6ZO1ezsFiX1y+bHd1ipyEHIJKvuprv0sLTBwLW8= +modernc.org/libc v1.61.13/go.mod h1:8F/uJWL/3nNil0Lgt1Dpz+GgkApWh04N3el3hxJcA6E= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.8.2 h1:cL9L4bcoAObu4NkxOlKWBWtNHIsnnACGF/TbqQ6sbcI= +modernc.org/memory v1.8.2/go.mod h1:ZbjSvMO5NQ1A2i3bWeDiVMxIorXwdClKE/0SZ+BMotU= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.36.0 h1:EQXNRn4nIS+gfsKeUTymHIz1waxuv5BzU7558dHSfH8= +modernc.org/sqlite v1.36.0/go.mod h1:7MPwH7Z6bREicF9ZVUR78P1IKuxfZ8mRIDHD0iD+8TU= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/hotkeys.go b/hotkeys.go new file mode 100644 index 0000000..3fb6912 --- /dev/null +++ b/hotkeys.go @@ -0,0 +1,149 @@ +package main + +import ( + "fmt" + "math" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +type HotKey struct { + desc string + shortcut string +} + +type HotKeys struct { + *tview.List + values []HotKey +} + +func NewHotkeys() *HotKeys { + list := tview.NewList(). + ShowSecondaryText(false).SetSelectedFocusOnly(true) + + return &HotKeys{ + List: list, + values: []HotKey{}, + } +} + +func (r *HotKeys) AddHotKey(desc string, shortcut string) *HotKeys { + r.values = append(r.values, HotKey{desc, shortcut}) + + return r +} + +func (r *HotKeys) Draw(screen tcell.Screen) { + r.Box.DrawForSubclass(screen, r) + x, y, width, height := r.GetInnerRect() + + totalHotkeys := len(r.values) + if totalHotkeys == 0 { + return + } + + maxHotkeyLength := 0 + for _, hotkey := range r.values { + hotkeyText := fmt.Sprintf("<%s> %s", string(hotkey.shortcut), hotkey.desc) + if len(hotkeyText) > maxHotkeyLength { + maxHotkeyLength = len(hotkeyText) + } + } + + columnWidth := maxHotkeyLength + 4 + + maxColumns := int(math.Max(1, float64(width)/float64(columnWidth))) + + rowsPerColumn := int(math.Ceil(float64(totalHotkeys) / float64(maxColumns))) + + if rowsPerColumn > height { + rowsPerColumn = height + maxColumns = int(math.Ceil(float64(totalHotkeys) / float64(rowsPerColumn))) + } + + for i, hotkey := range r.values { + column := i / rowsPerColumn + row := i % rowsPerColumn + + if column >= maxColumns { + break + } + + colX := x + (column * columnWidth) + + line := fmt.Sprintf("<%s> %s", string(hotkey.shortcut), hotkey.desc) + tview.Print(screen, line, colX, y+row, columnWidth, tview.AlignLeft, tcell.ColorYellow) + } +} + +func GetConnectionHotkeys() *HotKeys { + return NewHotkeys(). + AddHotKey("New Connection", "n"). + AddHotKey("Edit Connection", "e"). + AddHotKey("Delete Connection", "d"). + AddHotKey("Open Connection", "o"). + AddHotKey("Test Connection", "t"). + AddHotKey("Refresh Connections", "r"). + AddHotKey("Search", "/"). + AddHotKey("Sort by Name", "s"). + AddHotKey("Help", "h"). + AddHotKey("Quit", "q"). + AddHotKey("Up", "↑"). + AddHotKey("Down", "↓"). + AddHotKey("Enter", "⏎") +} + +func GetDatabaseHotkeys() *HotKeys { + return NewHotkeys(). + AddHotKey("Back to Connections", "b"). + AddHotKey("Refresh Data", "r"). + AddHotKey("Execute Query", "e"). + AddHotKey("Export Results", "x"). + AddHotKey("Filter Results", "f"). + AddHotKey("Copy Row", "c"). + AddHotKey("Copy Cell", "y"). + AddHotKey("Next Page", "n"). + AddHotKey("Previous Page", "p"). + AddHotKey("Toggle View Mode", "v"). + AddHotKey("Help", "?"). + AddHotKey("Quit", "q"). + AddHotKey("Up", "↑"). + AddHotKey("Down", "↓"). + AddHotKey("Left", "←"). + AddHotKey("Right", "→") +} + +func GetHelpHotkeys() *HotKeys { + return NewHotkeys(). + AddHotKey("Back", "b"). + AddHotKey("Scroll Up", "↑"). + AddHotKey("Scroll Down", "↓"). + AddHotKey("Quit", "q") +} + +func GetFormHotkeys() *HotKeys { + return NewHotkeys(). + AddHotKey("Next Field", "Tab"). + AddHotKey("Previous Field", "Shift+Tab"). + AddHotKey("Submit", "Enter"). + AddHotKey("Cancel", "Esc") +} + +func GetQueryHotkeys() *HotKeys { + return NewHotkeys(). + AddHotKey("Execute", "Ctrl+e"). + AddHotKey("Save Query", "Ctrl+s"). + AddHotKey("Load Query", "Ctrl+o"). + AddHotKey("Clear", "Ctrl+l"). + AddHotKey("Exit Editor", "Esc"). + AddHotKey("History", "Ctrl+h") +} + +func GetExportHotkeys() *HotKeys { + return NewHotkeys(). + AddHotKey("Export as CSV", "c"). + AddHotKey("Export as JSON", "j"). + AddHotKey("Export as SQL", "s"). + AddHotKey("Cancel", "Esc") +} diff --git a/keyring.go b/keyring.go index 0bb0f3d..a1d087c 100644 --- a/keyring.go +++ b/keyring.go @@ -1,6 +1,7 @@ package main import ( + "database/sql" "errors" "fmt" "log" @@ -8,148 +9,138 @@ import ( "path/filepath" "strings" + "github.com/google/uuid" "github.com/zalando/go-keyring" - bolt "go.etcd.io/bbolt" + _ "modernc.org/sqlite" ) const ( SERVICE = "termtable-app" - - LOCAL_BUCKET_NAME = "database_connections" ) func getAndOrCreateLocalDb() (string, error) { homeDir, err := os.UserHomeDir() - if err != nil { - return "", errors.New("Could not get home directory") + return "", errors.New("could not get home directory") } - localDb := fmt.Sprintf("%s/.termtable/connections.db", homeDir) + dbDir := filepath.Join(homeDir, ".termtable") + localDb := filepath.Join(dbDir, "termtable.db") - if _, err := os.Stat(localDb); os.IsNotExist(err) { - err := os.Mkdir(filepath.Dir(localDb), 0755) + if _, err := os.Stat(dbDir); os.IsNotExist(err) { + err := os.MkdirAll(dbDir, 0o755) if err != nil { log.Fatal("Could not create directory to store local db: ", err) } } - return localDb, nil -} - -func createBucket(db *bolt.DB) error { - // Start a writable transaction. - tx, err := db.Begin(true) - if err != nil { - return err - } - defer func() { - err = tx.Rollback() - }() - - // Use the transaction... - _, err = tx.CreateBucketIfNotExists([]byte(LOCAL_BUCKET_NAME)) - if err != nil { - return err + // Create the database file if it doesn't exist + if _, err := os.Stat(localDb); os.IsNotExist(err) { + file, err := os.Create(localDb) + if err != nil { + return "", fmt.Errorf("could not create database file: %v", err) + } + file.Close() } - // Commit the transaction and check for error. - if err := tx.Commit(); err != nil { - return err - } + return localDb, nil +} +func initDb(db *sql.DB) error { + // Create the table if it doesn't exist + _, err := db.Exec(` + CREATE TABLE IF NOT EXISTS database_connections ( + id uuid PRIMARY KEY, + name TEXT UNIQUE NOT NULL, + host TEXT NOT NULL, + port TEXT NOT NULL, + database_name TEXT NOT NULL + ) + `) return err } -func updateLocalDbConn(conn Connection) error { +func updateConnection(conn Connection) error { localDb, err := getAndOrCreateLocalDb() - if err != nil { return err } - db, err := bolt.Open(localDb, 0600, nil) + db, err := sql.Open("sqlite", localDb) if err != nil { log.Fatal(err) } defer db.Close() - err = createBucket(db) + err = initDb(db) if err != nil { return err } - err = db.Update(func(tx *bolt.Tx) error { - b := tx.Bucket([]byte(LOCAL_BUCKET_NAME)) - err := b.Put([]byte(conn.Name), []byte(fmt.Sprintf("%s:%s:%s", conn.Host, conn.Port, conn.Database))) - return err - }) + // Insert or replace the connection + _, err = db.Exec( + "INSERT OR REPLACE INTO database_connections (id, name, host, port, database_name) VALUES (?, ?, ?, ?, ?)", + conn.ID, conn.Name, conn.Host, conn.Port, conn.Database, + ) return err } -func deleteLocalDbConn(name string) error { +func DeleteConnection(name string) error { localDb, err := getAndOrCreateLocalDb() - if err != nil { return err } - db, err := bolt.Open(localDb, 0600, nil) + db, err := sql.Open("sqlite", localDb) if err != nil { log.Fatal(err) } defer db.Close() - err = createBucket(db) + err = initDb(db) if err != nil { return err } - err = db.Update(func(tx *bolt.Tx) error { - b := tx.Bucket([]byte(LOCAL_BUCKET_NAME)) - err := b.Delete([]byte(name)) - return err - }) - + _, err = db.Exec("DELETE FROM database_connections WHERE name = ?", name) return err } -func listLocalDbConn() (map[string]string, error) { - localDb, err := getAndOrCreateLocalDb() - +func listConnections() (map[string]string, error) { connections := make(map[string]string) + localDb, err := getAndOrCreateLocalDb() if err != nil { return connections, err } - db, err := bolt.Open(localDb, 0600, nil) + db, err := sql.Open("sqlite", localDb) if err != nil { log.Fatal(err) } defer db.Close() - err = createBucket(db) + err = initDb(db) if err != nil { return connections, err } - err = db.View(func(tx *bolt.Tx) error { - b := tx.Bucket([]byte(LOCAL_BUCKET_NAME)) - - c := b.Cursor() + rows, err := db.Query("SELECT id, name, host, port, database_name FROM database_connections") + if err != nil { + return connections, err + } + defer rows.Close() - for k, v := c.First(); k != nil; k, v = c.Next() { - key := string(k) - value := string(v) - connections[key] = value + for rows.Next() { + var id, name, host, port, database string + if err := rows.Scan(&id, &name, &host, &port, &database); err != nil { + log.Fatal(err) } + connections[name] = fmt.Sprintf("%s:%s:%s:%s", id, host, port, database) + } - return nil - }) - - return connections, err + return connections, rows.Err() } func createKeyringPassword(username string, password string) string { @@ -168,16 +159,14 @@ func parseKeyringPassword(password string) (string, string, error) { func SaveConnectionInKeyring(conn Connection) { // Save keyring part - password := createKeyringPassword(conn.User, conn.Pass) + password := createKeyringPassword(conn.User, conn.Password) err := keyring.Set(SERVICE, conn.Name, password) - if err != nil { log.Fatal("Could not save db credentials in keyring: ", err) } // Save rest to local storage - err = updateLocalDbConn(conn) - + err = updateConnection(conn) if err != nil { log.Fatal("Could not set keyring info into local db: ", err) } @@ -185,7 +174,6 @@ func SaveConnectionInKeyring(conn Connection) { func GetConnectionFromKeyring(name string) (string, string, error) { password, err := keyring.Get(SERVICE, name) - if err != nil { log.Fatal("Could not get credentials for connection: ", err) } @@ -194,24 +182,39 @@ func GetConnectionFromKeyring(name string) (string, string, error) { } func ListConnections() ([]Connection, error) { - connections, err := listLocalDbConn() + connections, err := listConnections() var conns []Connection if err != nil { - return conns, errors.New("Could not list connections") + return conns, errors.New("could not list connections") } for k, v := range connections { - hostPortDb := strings.Split(v, ":") - if len(hostPortDb) != 3 { + components := strings.Split(v, ":") + if len(components) != 4 { continue } + + user, password, err := GetConnectionFromKeyring(k) + if err != nil { + log.Fatal("Could not get user and password for db", err) + } + + id, err := uuid.Parse(components[0]) + if err != nil { + log.Println(components[0]) + log.Fatal("Invalid uuid found for connection", err) + } + conn := Connection{ + ID: id, Name: k, - Host: hostPortDb[0], - Port: hostPortDb[1], - Database: hostPortDb[2], + User: user, + Password: password, + Host: components[1], + Port: components[2], + Database: components[3], } conns = append(conns, conn) } diff --git a/layout.go b/layout.go new file mode 100644 index 0000000..82c5bf9 --- /dev/null +++ b/layout.go @@ -0,0 +1,17 @@ +package main + +import "github.com/rivo/tview" + +type Layout struct { + *tview.Flex + header *tview.Flex + content *tview.Pages +} + +func newLayout(direction int, header *tview.Flex, content *tview.Pages) Layout { + view := tview.NewFlex().SetDirection(direction). + AddItem(header, 0, 1, false). + AddItem(content, 0, 6, false) + + return Layout{view, header, content} +} diff --git a/main.go b/main.go index affb86b..6188c7c 100644 --- a/main.go +++ b/main.go @@ -1,217 +1,9 @@ package main -import ( - "fmt" - "io" - "log" - "strings" - - "github.com/charmbracelet/bubbles/list" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" -) - -type CurrentView string - -const ( - DEFAULT CurrentView = "DEFAULT" - NEW_CONNECTION CurrentView = "NEW_CONNECTION" - EDIT_CONNECTION CurrentView = "EDIT_CONNECTION" - JOIN_EXISTING CurrentView = "JOIN_EXISTING" - DATABASE_VIEW CurrentView = "DATABASE_VIEW" -) - -const ( - defaultWidth = 20 - listHeight = 14 -) - -// Primary ansi colours -const ( - WHITE = "15" - RED = "1" - GREEN = "2" - YELLOW = "3" - BLUE = "4" - MAGENTA = "5" - GREY = "240" - LIGHT_GREY = "244" -) - -var ( - titleStyle = lipgloss.NewStyle() - itemStyle = lipgloss.NewStyle().PaddingLeft(4) - paginationStyle = list.DefaultStyles().PaginationStyle.PaddingLeft(4) - quitTextStyle = lipgloss.NewStyle().Margin(1, 0, 2, 0) - helpStyle = blurredStyle.Copy().PaddingLeft(2) - cursorStyle = focusedItemStyle.Copy() - noStyle = lipgloss.NewStyle() - - selectedItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.Color(MAGENTA)) - focusedItemStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(RED)) - focusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(WHITE)) - blurredStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(GREY)) - successStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(GREEN)) - errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(RED)) - - width int = 100 - height int = 100 -) - -type item string - -func (i item) FilterValue() string { return "" } - -type itemDelegate struct{} - -func (d itemDelegate) Height() int { return 1 } -func (d itemDelegate) Spacing() int { return 0 } -func (d itemDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil } -func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { - i, ok := listItem.(item) - if !ok { - return - } - - str := fmt.Sprintf("%d. %s", index+1, i) - - fn := itemStyle.Render - if index == m.Index() { - fn = func(s ...string) string { - return selectedItemStyle.Render("> " + strings.Join(s, " ")) - } - } - - fmt.Fprint(w, fn(str)) -} - -type model struct { - list list.Model - newConnectionModel NewConnectionModel - currentView CurrentView - currentConnection Connection - openDatabase OpenDatabase - existingConnections ExistingConnectionsModel -} - -func (m model) updateEvents(msg tea.Msg) (model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.list.SetWidth(msg.Width) - width = msg.Width - height = msg.Height - return m, nil - - case tea.KeyMsg: - switch keypress := msg.String(); keypress { - case "q", "ctrl+c": - return m, tea.Quit - - case "enter": - i, ok := m.list.SelectedItem().(item) - if ok { - switch string(i) { - case "New Connection": - m.currentView = NEW_CONNECTION - m.newConnectionModel = - InitialNewConnectionModel() - case "Edit Connection": - m.currentView = EDIT_CONNECTION - case "Join Existing": - m.currentView = JOIN_EXISTING - m.existingConnections = NewExistingConnectionsModel() - } - } - return m, nil - } - } - - var cmd tea.Cmd - m.list, cmd = m.list.Update(msg) - return m, cmd -} - -func (m model) Init() tea.Cmd { - return nil -} - -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmd tea.Cmd - - switch m.currentView { - case NEW_CONNECTION: - m.newConnectionModel, cmd = m.newConnectionModel.Update(msg) - if m.newConnectionModel.connection.status == CONNECTED { - m.currentView = DATABASE_VIEW - m.currentConnection = m.newConnectionModel.connection - m.openDatabase = NewOpenDatabase(m.currentConnection) - - SaveConnectionInKeyring(m.currentConnection) - } - - if m.newConnectionModel.action == CANCEL { - m.currentView = DEFAULT - } - - case DATABASE_VIEW: - m.openDatabase, cmd = m.openDatabase.Update(msg) - if m.openDatabase.viewMode == QUIT { - m.currentView = DEFAULT - m.openDatabase = OpenDatabase{} - } - - case JOIN_EXISTING: - m.existingConnections, cmd = m.existingConnections.Update(msg) - if m.existingConnections.selectedConnection != nil { - m.currentView = DATABASE_VIEW - m.currentConnection = *m.existingConnections.selectedConnection - m.openDatabase = NewOpenDatabase(m.currentConnection) - } - - if m.existingConnections.back { - m.currentView = DEFAULT - } - - case DEFAULT, EDIT_CONNECTION: - m, cmd = m.updateEvents(msg) - } - - return m, cmd -} - -func (m model) View() string { - switch m.currentView { - case NEW_CONNECTION: - return quitTextStyle.Render(m.newConnectionModel.View()) - case EDIT_CONNECTION: - return quitTextStyle.Render("Edit Connection") - case JOIN_EXISTING: - return quitTextStyle.Render(m.existingConnections.View()) - case DATABASE_VIEW: - return quitTextStyle.Render(m.openDatabase.View()) - default: - return "\n" + m.list.View() - } -} - func main() { - items := []list.Item{ - item("New Connection"), - item("Edit Connection"), - item("Join Existing"), - } - - l := list.New(items, itemDelegate{}, defaultWidth, listHeight) - l.Title = "Welcome to TermTable" - l.SetShowStatusBar(false) - l.SetFilteringEnabled(false) - l.Styles.Title = titleStyle - l.Styles.PaginationStyle = paginationStyle - l.Styles.HelpStyle = helpStyle - - m := model{list: l, currentView: DEFAULT} + app := NewApp() - if _, err := tea.NewProgram(m, tea.WithAltScreen()).Run(); err != nil { - log.Fatal("Error running program:", err) + if err := app.Run(); err != nil { + panic(err) } } diff --git a/new_connection.go b/new_connection.go index f4c7380..a0c7b1a 100644 --- a/new_connection.go +++ b/new_connection.go @@ -1,14 +1,5 @@ package main -import ( - "fmt" - "strings" - - "github.com/charmbracelet/bubbles/cursor" - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" -) - type Action string const ( @@ -22,221 +13,4 @@ type TestStatus string const ( PASSED TestStatus = "PASSED" FAILED TestStatus = "FAILED" - NA TestStatus = "NA" -) - -var ( - focusedButton = focusedStyle.Copy().Render("[ Submit ]") - blurredButton = fmt.Sprintf("[ %s ]", blurredStyle.Render("Submit")) - - focusedTestButton = focusedStyle.Copy().Render("[ Test ]") - blurredTestButton = fmt.Sprintf("[ %s ]", blurredStyle.Render("Test")) - errorTestButton = fmt.Sprintf("[ %s ]", errorStyle.Render("Test")) - successTestButton = fmt.Sprintf("[ %s ]", successStyle.Render("Test")) ) - -type NewConnectionModel struct { - focusIndex int - inputs []textinput.Model - cursorMode cursor.Mode - connection Connection - testStatus TestStatus - action Action -} - -func InitialNewConnectionModel() NewConnectionModel { - var newConnectionInputs = []string{ - "Host", - "Port", - "User", - "Pass", - "Database", - "Name", - } - m := NewConnectionModel{ - inputs: make([]textinput.Model, len(newConnectionInputs)), - action: SUBMIT, - testStatus: NA, - } - - var t textinput.Model - for i, value := range newConnectionInputs { - t = textinput.New() - t.Cursor.Style = cursorStyle - t.CharLimit = 32 - t.Placeholder = value - - if i == 0 { - t.Focus() - t.PromptStyle = focusedItemStyle - t.TextStyle = focusedItemStyle - } - - if value == "Pass" { - t.EchoMode = textinput.EchoPassword - t.EchoCharacter = '•' - } - - m.inputs[i] = t - } - - return m -} - -func (m NewConnectionModel) Init() tea.Cmd { - return textinput.Blink -} - -func (m NewConnectionModel) Update(msg tea.Msg) (NewConnectionModel, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.String() { - case "ctrl+c", "esc": - m.action = CANCEL - return m, nil - - // Change cursor mode - case "ctrl+r": - m.cursorMode++ - if m.cursorMode > cursor.CursorHide { - m.cursorMode = cursor.CursorBlink - } - cmds := make([]tea.Cmd, len(m.inputs)) - for i := range m.inputs { - cmds[i] = m.inputs[i].Cursor.SetMode(m.cursorMode) - } - return m, tea.Batch(cmds...) - - // Handle button actions - case "left", "right": - if m.focusIndex == len(m.inputs) { - if m.action == SUBMIT { - m.action = TEST - } else { - m.action = SUBMIT - } - } - - if m.testStatus != NA { - m.testStatus = NA - } - - case "enter": - if m.focusIndex == len(m.inputs) { - conn := Connection{ - Host: m.inputs[0].Value(), - Port: m.inputs[1].Value(), - User: m.inputs[2].Value(), - Pass: m.inputs[3].Value(), - Database: m.inputs[4].Value(), - Name: m.inputs[5].Value(), - status: DISCONNECTED, - } - - switch m.action { - case SUBMIT: - if conn.TestConnection() == PASSED { - m.connection = conn - } - case TEST: - if m.testStatus == NA { - m.testStatus = conn.TestConnection() - } - - } - - } - - return m.updateInputStates() - - // Set focus to next input - case "tab", "shift+tab", "up", "down": - s := msg.String() - - // Cycle indexes - if s == "up" || s == "shift+tab" { - m.focusIndex-- - } else { - m.focusIndex++ - } - - if m.focusIndex > len(m.inputs) { - m.focusIndex = 0 - } else if m.focusIndex < 0 { - m.focusIndex = len(m.inputs) - } - - return m.updateInputStates() - } - } - - // Handle character input and blinking - cmd := m.updateInputs(msg) - - return m, cmd -} - -func (m *NewConnectionModel) updateInputStates() (NewConnectionModel, tea.Cmd) { - cmds := make([]tea.Cmd, len(m.inputs)) - for i := 0; i <= len(m.inputs)-1; i++ { - if i == m.focusIndex { - // Set focused state - cmds[i] = m.inputs[i].Focus() - m.inputs[i].PromptStyle = focusedItemStyle - m.inputs[i].TextStyle = focusedItemStyle - continue - } - // Remove focused state - m.inputs[i].Blur() - m.inputs[i].PromptStyle = noStyle - m.inputs[i].TextStyle = noStyle - } - - return *m, tea.Batch(cmds...) -} - -func (m *NewConnectionModel) updateInputs(msg tea.Msg) tea.Cmd { - cmds := make([]tea.Cmd, len(m.inputs)) - - // Only text inputs with Focus() set will respond, so it's safe to simply - // update all of them here without any further logic. - for i := range m.inputs { - m.inputs[i], cmds[i] = m.inputs[i].Update(msg) - } - - return tea.Batch(cmds...) -} - -func (m NewConnectionModel) View() string { - var b strings.Builder - - b.WriteString("New Connection\n\n") - - for i := range m.inputs { - b.WriteString(m.inputs[i].View()) - if i < len(m.inputs)-1 { - b.WriteRune('\n') - } - } - - submitButton := &blurredButton - testButton := &blurredTestButton - if m.focusIndex == len(m.inputs) { - switch m.action { - case SUBMIT: - submitButton = &focusedButton - case TEST: - switch m.testStatus { - case PASSED: - testButton = &successTestButton - case FAILED: - testButton = &errorTestButton - case NA: - testButton = &focusedTestButton - } - } - } - fmt.Fprintf(&b, "\n\n%s%s\n\n", *submitButton, *testButton) - - return paginationStyle.Render(b.String()) -} diff --git a/open_connection.go b/open_connection.go index a23ac41..2a5e42a 100644 --- a/open_connection.go +++ b/open_connection.go @@ -1,209 +1,72 @@ package main import ( - "fmt" - "io" - "strings" - - "github.com/charmbracelet/bubbles/list" - "github.com/charmbracelet/bubbles/table" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" -) - -var ( - modelStyle = lipgloss. - NewStyle(). - BorderStyle(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color(GREY)) - focusedModelStyle = lipgloss. - NewStyle(). - BorderStyle(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color(WHITE)) - - focusedModelSideBarStyle = lipgloss. - NewStyle(). - BorderStyle(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color(WHITE)) - - blurredModelSideBarStyle = lipgloss. - NewStyle(). - Foreground(lipgloss.Color(GREY)) - selectedTableStyle = lipgloss. - NewStyle(). - Foreground(lipgloss.Color(MAGENTA)) + "log" ) type ViewMode string -const ( - TABLES ViewMode = "TABLES" - OPEN ViewMode = "OPEN" - QUIT ViewMode = "QUIT" -) - -type tableItem string - -func (i tableItem) FilterValue() string { return "" } - -type tableItemDelegate struct{} - -func (d tableItemDelegate) Height() int { return 1 } -func (d tableItemDelegate) Spacing() int { return 0 } -func (d tableItemDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil } -func (d tableItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { - str, ok := listItem.(tableItem) - if !ok { - return - } - - fn := blurredModelSideBarStyle.Render - if index == m.Index() { - fn = func(s ...string) string { - return selectedTableStyle.Render(strings.Join(s, " ")) - } - } - - fmt.Fprint(w, fn(string(str))) -} - type OpenDatabase struct { - tables list.Model - viewMode ViewMode - selectedTable table.Model - params Connection + params Connection + openTable Table + schema string } func NewOpenDatabase(connParams Connection) OpenDatabase { - databaseTables := connParams.GetTableNames() - - listItems := []list.Item{} - for _, value := range databaseTables { - listItems = append(listItems, tableItem(value)) - } - openDatabase := OpenDatabase{ - tables: list.New(listItems, tableItemDelegate{}, 14, 14), - viewMode: TABLES, - params: connParams, + params: connParams, } - openDatabase.tables.SetShowHelp(false) - openDatabase.tables.SetShowTitle(false) - openDatabase.tables.SetShowStatusBar(false) - - openDatabase.setOpenTable() + openDatabase.setSchemas() return openDatabase } -func (db *OpenDatabase) setOpenTable() { - selectedItem := db.tables.SelectedItem() - tableName := string(selectedItem.(tableItem)) +func (db *OpenDatabase) setSchemas() { + schemas := db.params.GetSchemas() - selectedTable, err := db.openTable(tableName) + db.setTable(schemas, "schema", "schema_names") +} - if err != nil { - db.params.status = DISCONNECTED - return - } +func (db *OpenDatabase) getTablesInSchema() { + tables := db.params.GetTableNames(db.schema) - db.selectedTable = selectedTable - db.selectedTable.SetWidth(width / 2) - db.selectedTable.SetHeight(height / 2) + db.setTable(tables, "table", "table_names") } -func (db OpenDatabase) openTable(tableName string) (table.Model, error) { - tableData, err := db.params.SelectAll(tableName) - - if err != nil { - return db.selectedTable, err +func (db *OpenDatabase) setTable(tables []string, name, title string) { + if len(tables) == 0 { + return } - max_len := db.selectedTable.Width() / len(tableData.fields) - columns := make([]table.Column, len(tableData.fields)) - for i, field := range tableData.fields { - columns[i] = table.Column{Title: field, Width: max_len} - } + rows := make([][]string, len(tables)) - rows := make([]table.Row, len(tableData.values)) - for i, value := range tableData.values { - rows[i] = make(table.Row, len(value)) - copy(rows[i], value) + for i, table := range tables { + rows[i] = []string{table} } - t := table.New( - table.WithColumns(columns), - table.WithRows(rows), - table.WithFocused(true), - ) - - s := table.DefaultStyles() - s.Header = s.Header. - BorderStyle(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color("240")). - BorderBottom(true). - Bold(false) - s.Selected = s.Selected. - Foreground(lipgloss.Color("229")). - Background(lipgloss.Color("57")). - Bold(false) - t.SetStyles(s) - - return t, nil + db.openTable = Table{name: name, fields: []string{title}, values: rows} } -func (db OpenDatabase) Init() tea.Cmd { - return nil -} +func (db *OpenDatabase) setOpenTable(index int) { + tables := db.params.GetTableNames(db.schema) -func (db OpenDatabase) Update(msg tea.Msg) (OpenDatabase, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.String() { - case "q", "ctrl+c": - db.viewMode = QUIT - return db, nil - - case "left", "right": - switch db.viewMode { - case TABLES: - db.viewMode = OPEN - case OPEN: - db.viewMode = TABLES - } - } + if len(tables) <= index { + return } - var cmd tea.Cmd + tableName := tables[index] + + table, err := db.params.SelectAll(tableName) + if err != nil { + log.Fatal("Could not connect to db", err) - switch db.viewMode { - case TABLES: - db.tables, cmd = db.tables.Update(msg) - case OPEN: - db.selectedTable, cmd = db.selectedTable.Update(msg) + return } - return db, cmd + db.openTable = table } -func (db OpenDatabase) View() string { - s := fmt.Sprintf("%s / %s\n\n", db.params.Name, db.params.Database) - - tableLabels := db.tables.View() - - db.setOpenTable() - openTable := db.selectedTable.View() - - if db.viewMode == TABLES { - s += lipgloss.JoinHorizontal(lipgloss.Top, - focusedModelSideBarStyle.Render(tableLabels), - modelStyle.Render(openTable)) - } else { - s += lipgloss.JoinHorizontal(lipgloss.Top, - modelStyle.Render(tableLabels), - focusedModelStyle.Render(openTable)) - } - - return paginationStyle.Render(s) +func (db *OpenDatabase) setSchema(schema string) { + db.schema = schema } diff --git a/table.go b/table.go new file mode 100644 index 0000000..0e25109 --- /dev/null +++ b/table.go @@ -0,0 +1,135 @@ +package main + +import ( + "strings" + + "github.com/rivo/tview" +) + +type DisplayTable struct { + *tview.Table + columns []string + rows []Connection +} + +func newConnectionsTable(columns []string) *DisplayTable { + table := tview.NewTable() + + for i, header := range columns { + table.SetCell(0, i, tview.NewTableCell(header).SetExpansion(1)) + } + + table.SetBorderPadding(0, 0, 1, 1) + table.SetSelectable(true, false).Select(1, 0) + + connectionsTable := DisplayTable{table, columns, []Connection{}} + connectionsTable.getConnections() + + return &connectionsTable +} + +func (t *DisplayTable) getConnections() { + connections, err := ListConnections() + if err != nil { + return + } + + for i, conn := range connections { + values := conn.Row() + for j, value := range values { + t.SetCell(i+1, j, tview.NewTableCell(value)) + } + } + + t.rows = connections +} + +func (t *DisplayTable) getConnection() *Connection { + row, _ := t.GetSelection() + + if row == 0 { + return nil + } + + return &t.rows[row-1] +} + +func (t *DisplayTable) updateTable(filter string) { + t.Clear() + + for i, header := range t.columns { + t.SetCell(0, i, tview.NewTableCell(header).SetExpansion(1)) + } + + row := 1 + for _, conn := range t.rows { + lowerFilter := strings.ToLower(filter) + + if filter == "" || + strings.Contains(strings.ToLower(conn.Host), lowerFilter) || + strings.Contains(strings.ToLower(conn.User), lowerFilter) || + strings.Contains(strings.ToLower(conn.Database), lowerFilter) || + strings.Contains(strings.ToLower(conn.Name), lowerFilter) { + + values := conn.Row() + for j, value := range values { + t.SetCell(row, j, tview.NewTableCell(value)) + } + + row++ + } + } +} + +type DbTable struct { + *tview.Table + db OpenDatabase +} + +func newDbTable(db OpenDatabase) *DbTable { + t := tview.NewTable() + t.SetBorderPadding(0, 0, 1, 1) + t.SetSelectable(true, false).Select(1, 0) + + connectionsTable := DbTable{t, db} + connectionsTable.setTableRows() + + return &connectionsTable +} + +func (t *DbTable) setTableRows() { + table := t.db.openTable + + for i, header := range table.fields { + t.SetCell(0, i, tview.NewTableCell(header).SetExpansion(1)) + } + + for i, value := range t.db.openTable.values { + for j, value := range value { + t.SetCell(i+1, j, tview.NewTableCell(value)) + } + } +} + +func (t *DbTable) showSchema() { + row, col := t.GetSelection() + + cell := t.GetCell(row, col) + t.db.setSchema(cell.Text) + + t.db.getTablesInSchema() + + t.Clear().ScrollToBeginning() + + t.setTableRows() +} + +func (t *DbTable) showTables() { + row, _ := t.GetSelection() + + t.db.setOpenTable(row) + + t.Clear().ScrollToBeginning() + + t.setTableRows() +}