make things editable

This commit is contained in:
2025-07-11 22:58:07 -07:00
parent a2e5d43fe0
commit 28a7100d9a
2 changed files with 403 additions and 51 deletions

View File

@@ -6,10 +6,14 @@ A fully-featured terminal user interface for browsing SQLite databases built wit
- **Table Browser**: Browse all tables in your SQLite database with pagination - **Table Browser**: Browse all tables in your SQLite database with pagination
- **Search Functionality**: Search tables by name using `/` key - **Search Functionality**: Search tables by name using `/` key
- **Data Viewer**: View table data with pagination (20 rows per page) - **Data Viewer**: View table data with pagination and row highlighting
- **Row-Level Navigation**: Navigate through data rows with cursor highlighting
- **Data Search**: Search within table data using `/` key
- **Row Detail Modal**: View individual rows in a 2-column format (Column | Value)
- **Cell Editing**: Edit individual cell values with live database updates
- **SQL Query Interface**: Execute custom SQL queries with parameter support - **SQL Query Interface**: Execute custom SQL queries with parameter support
- **Responsive Design**: Adapts to terminal size and fits content to screen - **Responsive Design**: Adapts to terminal size and fits content to screen
- **Navigation**: Intuitive keyboard navigation - **Navigation**: Intuitive keyboard navigation throughout all modes
## Usage ## Usage
@@ -39,11 +43,31 @@ go run main.go sample.db
- `Backspace`: Delete characters - `Backspace`: Delete characters
### Table Data Mode ### Table Data Mode
- `↑/↓` or `k/j`: Navigate between data rows (with highlighting)
- `←/→` or `h/l`: Navigate between data pages - `←/→` or `h/l`: Navigate between data pages
- `/`: Start searching within table data
- `Enter`: View selected row in detail modal
- `Esc`: Return to table list - `Esc`: Return to table list
- `r`: Refresh current table data - `r`: Refresh current table data
- `q` or `Ctrl+C`: Quit - `q` or `Ctrl+C`: Quit
### Data Search Mode (when searching within table data)
- Type to search within all columns of the table
- `Enter` or `Esc`: Finish search
- `Backspace`: Delete characters
### Row Detail Modal
- `↑/↓` or `k/j`: Navigate between fields (Column | Value format)
- `Enter`: Edit selected field value
- `Esc`: Return to table data view
- `q` or `Ctrl+C`: Quit
### Cell Edit Mode
- Type new value for the selected cell
- `Enter`: Save changes to database
- `Esc`: Cancel editing and return to row detail
- `Backspace`: Delete characters
### SQL Query Mode ### SQL Query Mode
- Type your SQL query - Type your SQL query
- `Enter`: Execute query - `Enter`: Execute query
@@ -56,13 +80,28 @@ go run main.go sample.db
1. **Table Browsing**: Lists all tables in the database with pagination 1. **Table Browsing**: Lists all tables in the database with pagination
2. **Table Search**: Filter tables by name using `/` to search 2. **Table Search**: Filter tables by name using `/` to search
3. **Paginated Data View**: Shows table data with pagination (20 rows per page) 3. **Paginated Data View**: Shows table data with pagination (20 rows per page)
4. **Screen-Aware Display**: Content automatically fits terminal size 4. **Row Highlighting**: Cursor-based row selection with visual highlighting
5. **SQL Query Execution**: Execute custom SQL queries and view results 5. **Data Search**: Search within table data across all columns
6. **Error Handling**: Displays database errors gracefully 6. **Row Detail Modal**: 2-column view showing Column | Value for selected row
7. **Responsive UI**: Clean, styled interface that adapts to terminal size 7. **Cell Editing**: Live editing of individual cell values with database updates
8. **Column Information**: Shows column names and handles NULL values 8. **Primary Key Detection**: Uses primary keys for reliable row updates
9. **Navigation**: Intuitive keyboard shortcuts for all operations 9. **Screen-Aware Display**: Content automatically fits terminal size
10. **Dynamic Column Width**: Columns adjust to terminal width 10. **SQL Query Execution**: Execute custom SQL queries and view results
11. **Error Handling**: Displays database errors gracefully
12. **Responsive UI**: Clean, styled interface that adapts to terminal size
13. **Column Information**: Shows column names and handles NULL values
14. **Navigation**: Intuitive keyboard shortcuts for all operations
15. **Dynamic Column Width**: Columns adjust to terminal width
## Navigation Flow
```
Table List → Table Data → Row Detail → Cell Edit
↓ ↓ ↓ ↓
Search Data Search Field Nav Value Edit
↓ ↓ ↓ ↓
SQL Query Row Select Cell Select Save/Cancel
```
## Sample Database ## Sample Database
@@ -75,3 +114,11 @@ The included `sample.db` contains:
- [Bubble Tea](https://github.com/charmbracelet/bubbletea) - TUI framework - [Bubble Tea](https://github.com/charmbracelet/bubbletea) - TUI framework
- [Lip Gloss](https://github.com/charmbracelet/lipgloss) - Styling - [Lip Gloss](https://github.com/charmbracelet/lipgloss) - Styling
- [go-sqlite3](https://github.com/mattn/go-sqlite3) - SQLite driver - [go-sqlite3](https://github.com/mattn/go-sqlite3) - SQLite driver
## Database Updates
The application supports live editing of database records:
- Uses primary keys when available for reliable row identification
- Falls back to full-row matching when no primary key exists
- Updates are immediately reflected in the interface
- All changes are committed to the database in real-time

329
main.go
View File

@@ -22,6 +22,8 @@ const (
modeTableList viewMode = iota modeTableList viewMode = iota
modeTableData modeTableData
modeQuery modeQuery
modeRowDetail
modeEditCell
) )
type model struct { type model struct {
@@ -32,13 +34,21 @@ type model struct {
selectedTable int selectedTable int
tableListPage int tableListPage int
tableData [][]string tableData [][]string
filteredData [][]string
columns []string columns []string
primaryKeys []string
currentPage int currentPage int
totalRows int totalRows int
selectedRow int
selectedCol int
query string query string
queryInput string queryInput string
searchInput string searchInput string
dataSearchInput string
searching bool searching bool
dataSearching bool
editingValue string
originalValue string
cursor int cursor int
err error err error
width int width int
@@ -85,8 +95,13 @@ func initialModel(dbPath string) model {
currentPage: 0, currentPage: 0,
tableListPage: 0, tableListPage: 0,
filteredTables: []string{}, filteredTables: []string{},
filteredData: [][]string{},
searchInput: "", searchInput: "",
dataSearchInput: "",
searching: false, searching: false,
dataSearching: false,
selectedRow: 0,
selectedCol: 0,
width: 80, // default width width: 80, // default width
height: 24, // default height height: 24, // default height
} }
@@ -158,7 +173,7 @@ func (m *model) loadTableData() {
tableName := m.filteredTables[m.selectedTable] tableName := m.filteredTables[m.selectedTable]
// Get column info // Get column info and primary keys
rows, err := m.db.Query(fmt.Sprintf("PRAGMA table_info(%s)", tableName)) rows, err := m.db.Query(fmt.Sprintf("PRAGMA table_info(%s)", tableName))
if err != nil { if err != nil {
m.err = err m.err = err
@@ -167,6 +182,7 @@ func (m *model) loadTableData() {
defer rows.Close() defer rows.Close()
m.columns = []string{} m.columns = []string{}
m.primaryKeys = []string{}
for rows.Next() { for rows.Next() {
var cid int var cid int
var name, dataType string var name, dataType string
@@ -178,6 +194,9 @@ func (m *model) loadTableData() {
return return
} }
m.columns = append(m.columns, name) m.columns = append(m.columns, name)
if pk == 1 {
m.primaryKeys = append(m.primaryKeys, name)
}
} }
// Get total row count // Get total row count
@@ -222,6 +241,111 @@ func (m *model) loadTableData() {
} }
m.tableData = append(m.tableData, row) m.tableData = append(m.tableData, row)
} }
// Apply data filtering
m.filterData()
// Reset row selection if needed
if m.selectedRow >= len(m.filteredData) {
m.selectedRow = 0
}
}
func (m *model) filterData() {
if m.dataSearchInput == "" {
m.filteredData = make([][]string, len(m.tableData))
copy(m.filteredData, m.tableData)
} else {
m.filteredData = [][]string{}
searchLower := strings.ToLower(m.dataSearchInput)
for _, row := range m.tableData {
// Search in all columns of the row
found := false
for _, cell := range row {
if strings.Contains(strings.ToLower(cell), searchLower) {
found = true
break
}
}
if found {
m.filteredData = append(m.filteredData, row)
}
}
}
}
func (m *model) updateCell(rowIndex, colIndex int, newValue string) error {
if rowIndex >= len(m.filteredData) || colIndex >= len(m.columns) {
return fmt.Errorf("invalid row or column index")
}
tableName := m.filteredTables[m.selectedTable]
columnName := m.columns[colIndex]
// Build WHERE clause using primary keys or all columns if no primary key
var whereClause strings.Builder
var args []any
if len(m.primaryKeys) > 0 {
// Use primary keys for WHERE clause
for i, pkCol := range m.primaryKeys {
if i > 0 {
whereClause.WriteString(" AND ")
}
// Find the column index for this primary key
pkIndex := -1
for j, col := range m.columns {
if col == pkCol {
pkIndex = j
break
}
}
if pkIndex >= 0 {
whereClause.WriteString(fmt.Sprintf("%s = ?", pkCol))
args = append(args, m.filteredData[rowIndex][pkIndex])
}
}
} else {
// Use all columns for WHERE clause (less reliable but works)
for i, col := range m.columns {
if i > 0 {
whereClause.WriteString(" AND ")
}
whereClause.WriteString(fmt.Sprintf("%s = ?", col))
args = append(args, m.filteredData[rowIndex][i])
}
}
// Execute UPDATE
updateQuery := fmt.Sprintf("UPDATE %s SET %s = ? WHERE %s", tableName, columnName, whereClause.String())
args = append([]any{newValue}, args...)
_, err := m.db.Exec(updateQuery, args...)
if err != nil {
return err
}
// Update local data
m.filteredData[rowIndex][colIndex] = newValue
// Also update the original data if it exists
for i, row := range m.tableData {
if len(row) > colIndex {
// Simple comparison - this might not work perfectly for all cases
match := true
for j, cell := range row {
if j < len(m.filteredData[rowIndex]) && cell != m.filteredData[rowIndex][j] && j != colIndex {
match = false
break
}
}
if match {
m.tableData[i][colIndex] = newValue
break
}
}
}
return nil
} }
func (m *model) executeQuery() { func (m *model) executeQuery() {
@@ -284,7 +408,32 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.height = msg.Height m.height = msg.Height
case tea.KeyMsg: case tea.KeyMsg:
// Handle search mode first // Handle edit mode first
if m.mode == modeEditCell {
switch msg.String() {
case "esc":
m.mode = modeRowDetail
m.editingValue = ""
case "enter":
if err := m.updateCell(m.selectedRow, m.selectedCol, m.editingValue); err != nil {
m.err = err
} else {
m.mode = modeRowDetail
m.editingValue = ""
}
case "backspace":
if len(m.editingValue) > 0 {
m.editingValue = m.editingValue[:len(m.editingValue)-1]
}
default:
if len(msg.String()) == 1 {
m.editingValue += msg.String()
}
}
return m, nil
}
// Handle search modes
if m.searching { if m.searching {
switch msg.String() { switch msg.String() {
case "esc": case "esc":
@@ -308,6 +457,29 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
if m.dataSearching {
switch msg.String() {
case "esc":
m.dataSearching = false
m.dataSearchInput = ""
m.filterData()
case "enter":
m.dataSearching = false
m.filterData()
case "backspace":
if len(m.dataSearchInput) > 0 {
m.dataSearchInput = m.dataSearchInput[:len(m.dataSearchInput)-1]
m.filterData()
}
default:
if len(msg.String()) == 1 {
m.dataSearchInput += msg.String()
m.filterData()
}
}
return m, nil
}
switch msg.String() { switch msg.String() {
case "ctrl+c", "q": case "ctrl+c", "q":
return m, tea.Quit return m, tea.Quit
@@ -317,12 +489,18 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case modeTableData, modeQuery: case modeTableData, modeQuery:
m.mode = modeTableList m.mode = modeTableList
m.err = nil m.err = nil
case modeRowDetail:
m.mode = modeTableData
} }
case "/": case "/":
if m.mode == modeTableList { switch m.mode {
case modeTableList:
m.searching = true m.searching = true
m.searchInput = "" m.searchInput = ""
case modeTableData:
m.dataSearching = true
m.dataSearchInput = ""
} }
case "enter": case "enter":
@@ -331,8 +509,20 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if len(m.filteredTables) > 0 { if len(m.filteredTables) > 0 {
m.mode = modeTableData m.mode = modeTableData
m.currentPage = 0 m.currentPage = 0
m.selectedRow = 0
m.loadTableData() m.loadTableData()
} }
case modeTableData:
if len(m.filteredData) > 0 {
m.mode = modeRowDetail
m.selectedCol = 0
}
case modeRowDetail:
if len(m.filteredData) > 0 && m.selectedRow < len(m.filteredData) && m.selectedCol < len(m.columns) {
m.mode = modeEditCell
m.originalValue = m.filteredData[m.selectedRow][m.selectedCol]
m.editingValue = m.originalValue
}
case modeQuery: case modeQuery:
m.executeQuery() m.executeQuery()
} }
@@ -348,6 +538,14 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.tableListPage-- m.tableListPage--
} }
} }
case modeTableData:
if m.selectedRow > 0 {
m.selectedRow--
}
case modeRowDetail:
if m.selectedCol > 0 {
m.selectedCol--
}
} }
case "down", "j": case "down", "j":
@@ -361,6 +559,14 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.tableListPage++ m.tableListPage++
} }
} }
case modeTableData:
if m.selectedRow < len(m.filteredData)-1 {
m.selectedRow++
}
case modeRowDetail:
if m.selectedCol < len(m.columns)-1 {
m.selectedCol++
}
} }
case "left", "h": case "left", "h":
@@ -368,6 +574,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case modeTableData: case modeTableData:
if m.currentPage > 0 { if m.currentPage > 0 {
m.currentPage-- m.currentPage--
m.selectedRow = 0
m.loadTableData() m.loadTableData()
} }
case modeTableList: case modeTableList:
@@ -382,9 +589,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case "right", "l": case "right", "l":
switch m.mode { switch m.mode {
case modeTableData: case modeTableData:
maxPage := (m.totalRows - 1) / pageSize maxPage := max(0, (m.totalRows-1)/pageSize)
if m.currentPage < maxPage { if m.currentPage < maxPage {
m.currentPage++ m.currentPage++
m.selectedRow = 0
m.loadTableData() m.loadTableData()
} }
case modeTableList: case modeTableList:
@@ -409,9 +617,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
case "r": case "r":
if m.mode == modeTableList { switch m.mode {
case modeTableList:
m.loadTables() m.loadTables()
} else if m.mode == modeTableData { case modeTableData:
m.loadTableData()
case modeRowDetail:
m.loadTableData() m.loadTableData()
} }
@@ -499,14 +710,28 @@ func (m model) View() string {
maxPage := max(0, (m.totalRows-1)/pageSize) maxPage := max(0, (m.totalRows-1)/pageSize)
content.WriteString(titleStyle.Render(fmt.Sprintf("Table: %s (Page %d/%d)", tableName, m.currentPage+1, maxPage+1))) content.WriteString(titleStyle.Render(fmt.Sprintf("Table: %s (Page %d/%d)", tableName, m.currentPage+1, maxPage+1)))
content.WriteString("\n\n") content.WriteString("\n")
if len(m.tableData) == 0 { // Search bar for data
if m.dataSearching {
content.WriteString("\nSearch data: " + m.dataSearchInput + "_")
content.WriteString("\n")
} else if m.dataSearchInput != "" {
content.WriteString(fmt.Sprintf("\nFiltered by: %s (%d/%d rows)", m.dataSearchInput, len(m.filteredData), len(m.tableData)))
content.WriteString("\n")
}
content.WriteString("\n")
if len(m.filteredData) == 0 {
if m.dataSearchInput != "" {
content.WriteString("No rows match your search")
} else {
content.WriteString("No data in table") content.WriteString("No data in table")
}
} else { } else {
// Limit rows to fit screen // Limit rows to fit screen
visibleRows := m.getVisibleDataRowCount() visibleRows := m.getVisibleDataRowCount()
displayRows := min(len(m.tableData), visibleRows) displayRows := min(len(m.filteredData), visibleRows)
// Create table header // Create table header
var headerRow strings.Builder var headerRow strings.Builder
@@ -534,9 +759,12 @@ func (m model) View() string {
content.WriteString(separator.String()) content.WriteString(separator.String())
content.WriteString("\n") content.WriteString("\n")
// Add data rows // Add data rows with highlighting
for i := 0; i < displayRows; i++ { for i := 0; i < displayRows; i++ {
row := m.tableData[i] if i >= len(m.filteredData) {
break
}
row := m.filteredData[i]
var dataRow strings.Builder var dataRow strings.Builder
for j, cell := range row { for j, cell := range row {
if j > 0 { if j > 0 {
@@ -544,13 +772,90 @@ func (m model) View() string {
} }
dataRow.WriteString(fmt.Sprintf("%-*s", colWidth, truncateString(cell, colWidth))) dataRow.WriteString(fmt.Sprintf("%-*s", colWidth, truncateString(cell, colWidth)))
} }
if i == m.selectedRow {
content.WriteString(selectedStyle.Render(dataRow.String()))
} else {
content.WriteString(normalStyle.Render(dataRow.String())) content.WriteString(normalStyle.Render(dataRow.String()))
}
content.WriteString("\n") content.WriteString("\n")
} }
} }
content.WriteString("\n") content.WriteString("\n")
content.WriteString(helpStyle.Render(fmt.Sprintf("←/→: prev/next page • Total rows: %d • esc: back • r: refresh • q: quit", m.totalRows))) if m.dataSearching {
content.WriteString(helpStyle.Render("Type to search • enter/esc: finish search"))
} else {
content.WriteString(helpStyle.Render("↑/↓: select row • ←/→: page • /: search • enter: view row • esc: back • r: refresh • q: quit"))
}
case modeRowDetail:
tableName := m.filteredTables[m.selectedTable]
content.WriteString(titleStyle.Render(fmt.Sprintf("Row Detail: %s", tableName)))
content.WriteString("\n\n")
if m.selectedRow >= len(m.filteredData) {
content.WriteString("Invalid row selection")
} else {
row := m.filteredData[m.selectedRow]
// Show as 2-column table: Column | Value
colWidth := max(15, m.width/3)
valueWidth := max(20, m.width-colWidth-5)
// Header
headerRow := fmt.Sprintf("%-*s | %-*s", colWidth, "Column", valueWidth, "Value")
content.WriteString(selectedStyle.Render(headerRow))
content.WriteString("\n")
// Separator
separator := strings.Repeat("-", colWidth) + "-+-" + strings.Repeat("-", valueWidth)
content.WriteString(separator)
content.WriteString("\n")
// Data rows
visibleRows := m.getVisibleDataRowCount() - 4 // Account for header and title
displayRows := min(len(m.columns), visibleRows)
for i := 0; i < displayRows; i++ {
if i >= len(m.columns) || i >= len(row) {
break
}
col := m.columns[i]
val := row[i]
dataRow := fmt.Sprintf("%-*s | %-*s",
colWidth, truncateString(col, colWidth),
valueWidth, truncateString(val, valueWidth))
if i == m.selectedCol {
content.WriteString(selectedStyle.Render(dataRow))
} else {
content.WriteString(normalStyle.Render(dataRow))
}
content.WriteString("\n")
}
}
content.WriteString("\n")
content.WriteString(helpStyle.Render("↑/↓: select field • enter: edit • esc: back • q: quit"))
case modeEditCell:
tableName := m.filteredTables[m.selectedTable]
columnName := ""
if m.selectedCol < len(m.columns) {
columnName = m.columns[m.selectedCol]
}
content.WriteString(titleStyle.Render(fmt.Sprintf("Edit: %s.%s", tableName, columnName)))
content.WriteString("\n\n")
content.WriteString("Original: " + m.originalValue)
content.WriteString("\n")
content.WriteString("New: " + m.editingValue + "_")
content.WriteString("\n\n")
content.WriteString(helpStyle.Render("Type new value • enter: save • esc: cancel"))
case modeQuery: case modeQuery:
content.WriteString(titleStyle.Render("SQL Query")) content.WriteString(titleStyle.Render("SQL Query"))