mirror of
https://github.com/taigrr/teaqlite.git
synced 2026-04-02 04:59:03 -07:00
976 lines
23 KiB
Go
976 lines
23 KiB
Go
package main
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"strings"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
_ "github.com/mattn/go-sqlite3"
|
|
)
|
|
|
|
const (
|
|
pageSize = 20
|
|
)
|
|
|
|
type viewMode int
|
|
|
|
const (
|
|
modeTableList viewMode = iota
|
|
modeTableData
|
|
modeQuery
|
|
modeRowDetail
|
|
modeEditCell
|
|
)
|
|
|
|
type model struct {
|
|
db *sql.DB
|
|
mode viewMode
|
|
tables []string
|
|
filteredTables []string
|
|
selectedTable int
|
|
tableListPage int
|
|
tableData [][]string
|
|
filteredData [][]string
|
|
columns []string
|
|
primaryKeys []string
|
|
currentPage int
|
|
totalRows int
|
|
selectedRow int
|
|
selectedCol int
|
|
query string
|
|
queryInput string
|
|
searchInput string
|
|
dataSearchInput string
|
|
searching bool
|
|
dataSearching bool
|
|
editingValue string
|
|
originalValue string
|
|
cursor int
|
|
err error
|
|
width int
|
|
height int
|
|
}
|
|
|
|
var (
|
|
titleStyle = lipgloss.NewStyle().
|
|
Bold(true).
|
|
Foreground(lipgloss.Color("#FAFAFA")).
|
|
Background(lipgloss.Color("#7D56F4")).
|
|
Padding(0, 1)
|
|
|
|
selectedStyle = lipgloss.NewStyle().
|
|
Bold(true).
|
|
Foreground(lipgloss.Color("#FAFAFA")).
|
|
Background(lipgloss.Color("#F25D94"))
|
|
|
|
normalStyle = lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("#FAFAFA"))
|
|
|
|
errorStyle = lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("#FF0000")).
|
|
Bold(true)
|
|
|
|
helpStyle = lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("#626262"))
|
|
|
|
tableStyle = lipgloss.NewStyle().
|
|
Border(lipgloss.RoundedBorder()).
|
|
BorderForeground(lipgloss.Color("#874BFD")).
|
|
Padding(1, 2)
|
|
)
|
|
|
|
func initialModel(dbPath string) model {
|
|
db, err := sql.Open("sqlite3", dbPath)
|
|
if err != nil {
|
|
return model{err: err}
|
|
}
|
|
|
|
m := model{
|
|
db: db,
|
|
mode: modeTableList,
|
|
currentPage: 0,
|
|
tableListPage: 0,
|
|
filteredTables: []string{},
|
|
filteredData: [][]string{},
|
|
searchInput: "",
|
|
dataSearchInput: "",
|
|
searching: false,
|
|
dataSearching: false,
|
|
selectedRow: 0,
|
|
selectedCol: 0,
|
|
width: 80, // default width
|
|
height: 24, // default height
|
|
}
|
|
|
|
m.loadTables()
|
|
return m
|
|
}
|
|
|
|
func (m *model) loadTables() {
|
|
rows, err := m.db.Query("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
|
|
if err != nil {
|
|
m.err = err
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
m.tables = []string{}
|
|
for rows.Next() {
|
|
var name string
|
|
if err := rows.Scan(&name); err != nil {
|
|
m.err = err
|
|
return
|
|
}
|
|
m.tables = append(m.tables, name)
|
|
}
|
|
m.filterTables()
|
|
}
|
|
|
|
func (m *model) filterTables() {
|
|
if m.searchInput == "" {
|
|
m.filteredTables = make([]string, len(m.tables))
|
|
copy(m.filteredTables, m.tables)
|
|
} else {
|
|
m.filteredTables = []string{}
|
|
searchLower := strings.ToLower(m.searchInput)
|
|
for _, table := range m.tables {
|
|
if strings.Contains(strings.ToLower(table), searchLower) {
|
|
m.filteredTables = append(m.filteredTables, table)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Reset selection and page if needed
|
|
if m.selectedTable >= len(m.filteredTables) {
|
|
m.selectedTable = 0
|
|
m.tableListPage = 0
|
|
}
|
|
}
|
|
|
|
func (m *model) getVisibleTableCount() int {
|
|
// Reserve space for title (3 lines), help (3 lines), search bar (2 lines if searching)
|
|
reservedLines := 8
|
|
if m.searching {
|
|
reservedLines += 2
|
|
}
|
|
return max(1, m.height-reservedLines)
|
|
}
|
|
|
|
func (m *model) getVisibleDataRowCount() int {
|
|
// Reserve space for title (3 lines), header (3 lines), help (3 lines)
|
|
reservedLines := 9
|
|
return max(1, m.height-reservedLines)
|
|
}
|
|
|
|
func (m *model) loadTableData() {
|
|
if m.selectedTable >= len(m.filteredTables) {
|
|
return
|
|
}
|
|
|
|
tableName := m.filteredTables[m.selectedTable]
|
|
|
|
// Get column info and primary keys
|
|
rows, err := m.db.Query(fmt.Sprintf("PRAGMA table_info(%s)", tableName))
|
|
if err != nil {
|
|
m.err = err
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
m.columns = []string{}
|
|
m.primaryKeys = []string{}
|
|
for rows.Next() {
|
|
var cid int
|
|
var name, dataType string
|
|
var notNull, pk int
|
|
var defaultValue sql.NullString
|
|
|
|
if err := rows.Scan(&cid, &name, &dataType, ¬Null, &defaultValue, &pk); err != nil {
|
|
m.err = err
|
|
return
|
|
}
|
|
m.columns = append(m.columns, name)
|
|
if pk == 1 {
|
|
m.primaryKeys = append(m.primaryKeys, name)
|
|
}
|
|
}
|
|
|
|
// Get total row count
|
|
countQuery := fmt.Sprintf("SELECT COUNT(*) FROM %s", tableName)
|
|
err = m.db.QueryRow(countQuery).Scan(&m.totalRows)
|
|
if err != nil {
|
|
m.err = err
|
|
return
|
|
}
|
|
|
|
// Get paginated data
|
|
offset := m.currentPage * pageSize
|
|
dataQuery := fmt.Sprintf("SELECT * FROM %s LIMIT %d OFFSET %d", tableName, pageSize, offset)
|
|
|
|
rows, err = m.db.Query(dataQuery)
|
|
if err != nil {
|
|
m.err = err
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
m.tableData = [][]string{}
|
|
for rows.Next() {
|
|
values := make([]any, len(m.columns))
|
|
valuePtrs := make([]any, len(m.columns))
|
|
for i := range values {
|
|
valuePtrs[i] = &values[i]
|
|
}
|
|
|
|
if err := rows.Scan(valuePtrs...); err != nil {
|
|
m.err = err
|
|
return
|
|
}
|
|
|
|
row := make([]string, len(m.columns))
|
|
for i, val := range values {
|
|
if val == nil {
|
|
row[i] = "NULL"
|
|
} else {
|
|
row[i] = fmt.Sprintf("%v", val)
|
|
}
|
|
}
|
|
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() {
|
|
if strings.TrimSpace(m.query) == "" {
|
|
return
|
|
}
|
|
|
|
rows, err := m.db.Query(m.query)
|
|
if err != nil {
|
|
m.err = err
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
// Get column names
|
|
columns, err := rows.Columns()
|
|
if err != nil {
|
|
m.err = err
|
|
return
|
|
}
|
|
m.columns = columns
|
|
|
|
// Get data
|
|
m.tableData = [][]string{}
|
|
for rows.Next() {
|
|
values := make([]any, len(m.columns))
|
|
valuePtrs := make([]any, len(m.columns))
|
|
for i := range values {
|
|
valuePtrs[i] = &values[i]
|
|
}
|
|
|
|
if err := rows.Scan(valuePtrs...); err != nil {
|
|
m.err = err
|
|
return
|
|
}
|
|
|
|
row := make([]string, len(m.columns))
|
|
for i, val := range values {
|
|
if val == nil {
|
|
row[i] = "NULL"
|
|
} else {
|
|
row[i] = fmt.Sprintf("%v", val)
|
|
}
|
|
}
|
|
m.tableData = append(m.tableData, row)
|
|
}
|
|
|
|
m.totalRows = len(m.tableData)
|
|
m.currentPage = 0
|
|
}
|
|
|
|
func (m model) Init() tea.Cmd {
|
|
return nil
|
|
}
|
|
|
|
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case tea.WindowSizeMsg:
|
|
m.width = msg.Width
|
|
m.height = msg.Height
|
|
|
|
case tea.KeyMsg:
|
|
// 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 {
|
|
switch msg.String() {
|
|
case "esc":
|
|
m.searching = false
|
|
m.searchInput = ""
|
|
m.filterTables()
|
|
case "enter":
|
|
m.searching = false
|
|
m.filterTables()
|
|
case "backspace":
|
|
if len(m.searchInput) > 0 {
|
|
m.searchInput = m.searchInput[:len(m.searchInput)-1]
|
|
m.filterTables()
|
|
}
|
|
default:
|
|
if len(msg.String()) == 1 {
|
|
m.searchInput += msg.String()
|
|
m.filterTables()
|
|
}
|
|
}
|
|
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() {
|
|
case "ctrl+c", "q":
|
|
return m, tea.Quit
|
|
|
|
case "esc":
|
|
switch m.mode {
|
|
case modeTableData, modeQuery:
|
|
m.mode = modeTableList
|
|
m.err = nil
|
|
case modeRowDetail:
|
|
m.mode = modeTableData
|
|
}
|
|
|
|
case "/":
|
|
switch m.mode {
|
|
case modeTableList:
|
|
m.searching = true
|
|
m.searchInput = ""
|
|
case modeTableData:
|
|
m.dataSearching = true
|
|
m.dataSearchInput = ""
|
|
}
|
|
|
|
case "enter":
|
|
switch m.mode {
|
|
case modeTableList:
|
|
if len(m.filteredTables) > 0 {
|
|
m.mode = modeTableData
|
|
m.currentPage = 0
|
|
m.selectedRow = 0
|
|
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:
|
|
m.executeQuery()
|
|
}
|
|
|
|
case "up", "k":
|
|
switch m.mode {
|
|
case modeTableList:
|
|
if m.selectedTable > 0 {
|
|
m.selectedTable--
|
|
// Check if we need to scroll up
|
|
visibleCount := m.getVisibleTableCount()
|
|
if m.selectedTable < m.tableListPage*visibleCount {
|
|
m.tableListPage--
|
|
}
|
|
}
|
|
case modeTableData:
|
|
if m.selectedRow > 0 {
|
|
m.selectedRow--
|
|
}
|
|
case modeRowDetail:
|
|
if m.selectedCol > 0 {
|
|
m.selectedCol--
|
|
}
|
|
}
|
|
|
|
case "down", "j":
|
|
switch m.mode {
|
|
case modeTableList:
|
|
if m.selectedTable < len(m.filteredTables)-1 {
|
|
m.selectedTable++
|
|
// Check if we need to scroll down
|
|
visibleCount := m.getVisibleTableCount()
|
|
if m.selectedTable >= (m.tableListPage+1)*visibleCount {
|
|
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":
|
|
switch m.mode {
|
|
case modeTableData:
|
|
if m.currentPage > 0 {
|
|
m.currentPage--
|
|
m.selectedRow = 0
|
|
m.loadTableData()
|
|
}
|
|
case modeTableList:
|
|
if m.tableListPage > 0 {
|
|
m.tableListPage--
|
|
// Adjust selection to stay in view
|
|
visibleCount := m.getVisibleTableCount()
|
|
m.selectedTable = m.tableListPage * visibleCount
|
|
}
|
|
}
|
|
|
|
case "right", "l":
|
|
switch m.mode {
|
|
case modeTableData:
|
|
maxPage := max(0, (m.totalRows-1)/pageSize)
|
|
if m.currentPage < maxPage {
|
|
m.currentPage++
|
|
m.selectedRow = 0
|
|
m.loadTableData()
|
|
}
|
|
case modeTableList:
|
|
visibleCount := m.getVisibleTableCount()
|
|
maxPage := (len(m.filteredTables) - 1) / visibleCount
|
|
if m.tableListPage < maxPage {
|
|
m.tableListPage++
|
|
// Adjust selection to stay in view
|
|
m.selectedTable = m.tableListPage * visibleCount
|
|
if m.selectedTable >= len(m.filteredTables) {
|
|
m.selectedTable = len(m.filteredTables) - 1
|
|
}
|
|
}
|
|
}
|
|
|
|
case "s":
|
|
if m.mode == modeTableList {
|
|
m.mode = modeQuery
|
|
m.queryInput = ""
|
|
m.query = ""
|
|
m.cursor = 0
|
|
}
|
|
|
|
case "r":
|
|
switch m.mode {
|
|
case modeTableList:
|
|
m.loadTables()
|
|
case modeTableData:
|
|
m.loadTableData()
|
|
case modeRowDetail:
|
|
m.loadTableData()
|
|
}
|
|
|
|
case "backspace":
|
|
if m.mode == modeQuery && len(m.queryInput) > 0 {
|
|
m.queryInput = m.queryInput[:len(m.queryInput)-1]
|
|
m.query = m.queryInput
|
|
}
|
|
|
|
default:
|
|
if m.mode == modeQuery {
|
|
if len(msg.String()) == 1 {
|
|
m.queryInput += msg.String()
|
|
m.query = m.queryInput
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
func (m model) View() string {
|
|
if m.err != nil {
|
|
return errorStyle.Render(fmt.Sprintf("Error: %v\n\nPress 'esc' to continue or 'q' to quit", m.err))
|
|
}
|
|
|
|
var content strings.Builder
|
|
|
|
switch m.mode {
|
|
case modeTableList:
|
|
// Title
|
|
content.WriteString(titleStyle.Render("SQLite TUI - Tables"))
|
|
content.WriteString("\n")
|
|
|
|
// Search bar
|
|
if m.searching {
|
|
content.WriteString("\nSearch: " + m.searchInput + "_")
|
|
content.WriteString("\n")
|
|
} else if m.searchInput != "" {
|
|
content.WriteString(fmt.Sprintf("\nFiltered by: %s (%d/%d tables)", m.searchInput, len(m.filteredTables), len(m.tables)))
|
|
content.WriteString("\n")
|
|
}
|
|
content.WriteString("\n")
|
|
|
|
// Table list with pagination
|
|
if len(m.filteredTables) == 0 {
|
|
if m.searchInput != "" {
|
|
content.WriteString("No tables match your search")
|
|
} else {
|
|
content.WriteString("No tables found in database")
|
|
}
|
|
} else {
|
|
visibleCount := m.getVisibleTableCount()
|
|
startIdx := m.tableListPage * visibleCount
|
|
endIdx := min(startIdx+visibleCount, len(m.filteredTables))
|
|
|
|
for i := startIdx; i < endIdx; i++ {
|
|
table := m.filteredTables[i]
|
|
if i == m.selectedTable {
|
|
content.WriteString(selectedStyle.Render(fmt.Sprintf("> %s", table)))
|
|
} else {
|
|
content.WriteString(normalStyle.Render(fmt.Sprintf(" %s", table)))
|
|
}
|
|
content.WriteString("\n")
|
|
}
|
|
|
|
// Show pagination info
|
|
if len(m.filteredTables) > visibleCount {
|
|
totalPages := (len(m.filteredTables) - 1) / visibleCount + 1
|
|
content.WriteString(fmt.Sprintf("\nPage %d/%d", m.tableListPage+1, totalPages))
|
|
}
|
|
}
|
|
|
|
// Help
|
|
content.WriteString("\n")
|
|
if m.searching {
|
|
content.WriteString(helpStyle.Render("Type to search • enter/esc: finish search"))
|
|
} else {
|
|
content.WriteString(helpStyle.Render("↑/↓: navigate • ←/→: page • /: search • enter: view • s: SQL • r: refresh • q: quit"))
|
|
}
|
|
|
|
case modeTableData:
|
|
tableName := m.filteredTables[m.selectedTable]
|
|
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("\n")
|
|
|
|
// 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")
|
|
}
|
|
} else {
|
|
// Limit rows to fit screen
|
|
visibleRows := m.getVisibleDataRowCount()
|
|
displayRows := min(len(m.filteredData), visibleRows)
|
|
|
|
// Create table header
|
|
var headerRow strings.Builder
|
|
colWidth := 10 // default minimum width
|
|
if len(m.columns) > 0 && m.width > 0 {
|
|
colWidth = max(10, (m.width-len(m.columns)*3)/len(m.columns))
|
|
}
|
|
for i, col := range m.columns {
|
|
if i > 0 {
|
|
headerRow.WriteString(" | ")
|
|
}
|
|
headerRow.WriteString(fmt.Sprintf("%-*s", colWidth, truncateString(col, colWidth)))
|
|
}
|
|
content.WriteString(selectedStyle.Render(headerRow.String()))
|
|
content.WriteString("\n")
|
|
|
|
// Add separator
|
|
var separator strings.Builder
|
|
for i := range m.columns {
|
|
if i > 0 {
|
|
separator.WriteString("-+-")
|
|
}
|
|
separator.WriteString(strings.Repeat("-", colWidth))
|
|
}
|
|
content.WriteString(separator.String())
|
|
content.WriteString("\n")
|
|
|
|
// Add data rows with highlighting
|
|
for i := 0; i < displayRows; i++ {
|
|
if i >= len(m.filteredData) {
|
|
break
|
|
}
|
|
row := m.filteredData[i]
|
|
var dataRow strings.Builder
|
|
for j, cell := range row {
|
|
if j > 0 {
|
|
dataRow.WriteString(" | ")
|
|
}
|
|
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("\n")
|
|
}
|
|
}
|
|
|
|
content.WriteString("\n")
|
|
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:
|
|
content.WriteString(titleStyle.Render("SQL Query"))
|
|
content.WriteString("\n\n")
|
|
|
|
content.WriteString("Query: ")
|
|
content.WriteString(m.queryInput)
|
|
content.WriteString("_") // cursor
|
|
content.WriteString("\n\n")
|
|
|
|
if len(m.tableData) > 0 {
|
|
// Limit rows to fit screen
|
|
visibleRows := m.getVisibleDataRowCount() - 2 // Account for query input
|
|
displayRows := min(len(m.tableData), visibleRows)
|
|
|
|
// Show query results
|
|
colWidth := 10 // default minimum width
|
|
if len(m.columns) > 0 && m.width > 0 {
|
|
colWidth = max(10, (m.width-len(m.columns)*3)/len(m.columns))
|
|
}
|
|
var headerRow strings.Builder
|
|
for i, col := range m.columns {
|
|
if i > 0 {
|
|
headerRow.WriteString(" | ")
|
|
}
|
|
headerRow.WriteString(fmt.Sprintf("%-*s", colWidth, truncateString(col, colWidth)))
|
|
}
|
|
content.WriteString(selectedStyle.Render(headerRow.String()))
|
|
content.WriteString("\n")
|
|
|
|
var separator strings.Builder
|
|
for i := range m.columns {
|
|
if i > 0 {
|
|
separator.WriteString("-+-")
|
|
}
|
|
separator.WriteString(strings.Repeat("-", colWidth))
|
|
}
|
|
content.WriteString(separator.String())
|
|
content.WriteString("\n")
|
|
|
|
for i := 0; i < displayRows; i++ {
|
|
row := m.tableData[i]
|
|
var dataRow strings.Builder
|
|
for j, cell := range row {
|
|
if j > 0 {
|
|
dataRow.WriteString(" | ")
|
|
}
|
|
dataRow.WriteString(fmt.Sprintf("%-*s", colWidth, truncateString(cell, colWidth)))
|
|
}
|
|
content.WriteString(normalStyle.Render(dataRow.String()))
|
|
content.WriteString("\n")
|
|
}
|
|
|
|
if len(m.tableData) > displayRows {
|
|
content.WriteString(helpStyle.Render(fmt.Sprintf("... and %d more rows", len(m.tableData)-displayRows)))
|
|
content.WriteString("\n")
|
|
}
|
|
}
|
|
|
|
content.WriteString("\n")
|
|
content.WriteString(helpStyle.Render("enter: execute query • esc: back • q: quit"))
|
|
}
|
|
|
|
// Ensure content fits in screen height
|
|
lines := strings.Split(content.String(), "\n")
|
|
maxLines := max(1, m.height-2)
|
|
if len(lines) > maxLines {
|
|
lines = lines[:maxLines]
|
|
content.Reset()
|
|
content.WriteString(strings.Join(lines, "\n"))
|
|
}
|
|
|
|
return content.String()
|
|
}
|
|
|
|
func truncateString(s string, maxLen int) string {
|
|
if len(s) <= maxLen {
|
|
return s
|
|
}
|
|
return s[:maxLen-3] + "..."
|
|
}
|
|
|
|
func min(a, b int) int {
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
func max(a, b int) int {
|
|
if a > b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
func main() {
|
|
if len(os.Args) < 2 {
|
|
fmt.Println("Usage: go-sqlite-tui <database.db>")
|
|
os.Exit(1)
|
|
}
|
|
|
|
dbPath := os.Args[1]
|
|
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
|
fmt.Printf("Database file '%s' does not exist\n", dbPath)
|
|
os.Exit(1)
|
|
}
|
|
|
|
m := initialModel(dbPath)
|
|
if m.err != nil {
|
|
log.Fatal(m.err)
|
|
}
|
|
|
|
p := tea.NewProgram(m, tea.WithAltScreen())
|
|
if _, err := p.Run(); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
} |