Repository: mathaou/sqlite-tui
Branch: master
Commit: be6f39719607
Files: 32
Total size: 168.2 KB
Directory structure:
gitextract_ax31ssze/
├── .github/
│ └── workflows/
│ └── codeql-analysis.yml
├── .gitignore
├── CHANGELOG.txt
├── LICENSE
├── README.md
├── database/
│ ├── query.go
│ └── sqlite.go
├── go.mod
├── go.sum
├── list/
│ ├── defaultitem.go
│ ├── keys.go
│ ├── list.go
│ └── style.go
├── main.go
├── tuiutil/
│ ├── csv2sql.go
│ ├── textinput.go
│ ├── theme.go
│ └── wordwrap.go
└── viewer/
├── defs.go
├── events.go
├── global.go
├── help.go
├── lineedit.go
├── mode.go
├── modelutil.go
├── serialize.go
├── snippets.go
├── table.go
├── tableutil.go
├── ui.go
├── util.go
└── viewer.go
================================================
FILE CONTENTS
================================================
================================================
FILE: .github/workflows/codeql-analysis.yml
================================================
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL"
on:
push:
branches: [ master ]
pull_request:
# The branches below must be a subset of the branches above
branches: [ master ]
schedule:
- cron: '38 18 * * 1'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [ 'go' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
# Learn more:
# https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
steps:
- name: Checkout repository
uses: actions/checkout@v2
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v1
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v1
# ℹ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language
#- run: |
# make bootstrap
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1
================================================
FILE: .gitignore
================================================
*.sh
.idea/*
.termdbms
build/
================================================
FILE: CHANGELOG.txt
================================================
## [Unreleased]
- MYSQL Support
- Database creation tools
##[1.0-alpha]
### Added
- Added ability to remove snippets
- Line wrapping + horizontal scroll!
### Changed
- Unified help text display logic with selection display logic
##[0.7-alpha] - 10-4-2021
### Added
- SQL querying
- Clipboard for SQL queries
### Changed
- Refactored codebase to be easier to reason with and manage
- Many bug fixes I forgot about
This changelog was started with 0.7-alpha, so the details before this are a little hazy. But here's to better transparency going forward!
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2021 Matt Farstad
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
# termdbms
## A TUI for viewing and editing databases, written in pure Go
#### Installation Instructions
###### Go Install
```go
go install github.com/mathaou/termdbms@latest
```
###### Arch Linux
```bash
// pacman
sudo pacman -S termdbms-git
// yay
yay -S termdbms-git
```
---
###### Database Support
SQLite
CSV* (see note below)
### made with modernc.org/sqlite, charmbracelet/bubbletea, and charmbracelet/lipgloss
#### Works with keyboard and mouse!

#### Navigate tables with any number of columns!

#### Navigate tables with any number of rows!

#### Serialize your changes as a copy or over the database original! (SQLite only)

#### Query your database!

#### Other Features
- Run SQL queries and display the results!
- Save SQL queries to a clipboard!
- Update, delete, or insert with SQL, with undo/redo supported for SQLite
- Automatic JSON formatting in selection/format mode
- Edit multi-line text with vim-like controls
- Undo/Redo of changes (SQLite only)
- Themes (press T in table mode)
- Output query results as a csv
- Convert .csv to SQLite database! Export as a SQLite database or .csv file again!
#### Roadmap
- MySQL/ PostgreSQL support
- Line wrapping / horizontal scroll for format/SQL mode + revamped (faster format mode)
####
<details>
<summary>How To Build</summary>
##### Linux
GOOS=linux GOARCH=amd64/386 go build
##### ARM (runs kind of slow depending on the specs of the system)
GOOS=linux GOARCH=arm GOARM=7 go build
##### Windows
GOOS=windows GOARCH=amd64/386 go build
##### OSX
GOOS=darwin GOARCH=amd64 go build
</details>
#### Terminal settings
Whatever terminal emulator used should support ANSI escape sequences. If there is an option for 256 color mode, enable it. If not available, try running program in ascii mode (-a).
#### Known Issues
- Using termdbms over a serial connection works very poorly. This is due to ANSI sequences not being supported natively. Maybe putty/mobaxterm have settings to allow this?
- The headers wig out sometimes in selection mode
- Line wrapping is not yet implemented, so text in format mode should be less than the maximum number of columns available per line for best use. It's in the works!
- Weird combinations of newlines + tabs can break stuff. Tabs at beginning of line and mid-line works in a stable manner.
##### Help:
-p / database/.csv path
-d / specifies which database driver to use (sqlite/mysql)
-a / enable ascii mode
-h / prints this message
-t / starts app with specific theme (default, nord, solarized)
##### Controls:
###### MOUSE
Scroll up + down to navigate table/text
Move cursor to select cells for full screen viewing
###### KEYBOARD
[WASD] to move around cells, and also move columns if close to edge
[ENTER] to select selected cell for full screen view
[UP/K and DOWN/J] to navigate schemas
[LEFT/H and RIGHT/L] to navigate columns if there are more than the screen allows.
Also to control the cursor of the text editor in edit mode
[BACKSPACE] to delete text before cursor in edit mode
[M(scroll up) and N(scroll down)] to scroll manually
[Q or CTRL+C] to quit program
[B] to toggle borders!
[C] to expand column
[T] to cycle through themes!
[P] in selection mode to write cell to file, or to print query results as CSV.
[R] to redo actions, if applicable
[U] to undo actions, if applicable
[ESC] to exit full screen view, or to enter edit mode
[PGDOWN] to scroll down one views worth of rows
[PGUP] to scroll up one views worth of rows
###### EDIT MODE (for quick, single line changes and commands)
[ESC] to enter edit mode with no pre-loaded text input from selection
When a cell is selected, press [:] to enter edit mode with selection pre-loaded
The text field in the header will be populated with the selected cells text. Modifications can be made freely
[ESC] to clear text field in edit mode
[ENTER] to save text. Anything besides one of the reserved strings below will overwrite the current cell
[:q] to exit edit mode/ format mode/ SQL mode
[:s] to save database to a new file (SQLite only)
[:s!] to overwrite original database file (SQLite only). A confirmation dialog will be added soon
[:h] to display help text
[:new] opens current cell with a blank buffer
[:edit] opens current cell in format mode
[:sql] opens blank buffer for creating an SQL statement
[:clip] to open clipboard of SQL queries. [/] to filter, [ENTER] to select.
[HOME] to set cursor to end of the text
[END] to set cursor to the end of the text
###### FORMAT MODE (for editing lines of text)
[ESC] to move between top control bar and format buffer
[HOME] to set cursor to end of the text
[END] to set cursor to the end of the text
[:wq] to save changes and quit to main table view
[:w] to save changes and remain in format view
[:s] to serialize changes, non-destructive (SQLite only)
[:s!] to serialize changes, overwriting original file (SQLite only)
###### SQL MODE (for querying database)
[ESC] to move between top control bar and text buffer
[:q] to quit out of statement
[:exec] to execute statement. Errors will be displayed in full screen view.
[:stow <NAME>] to create a snippet for the clipboard with an optional name. A random number will be used if no name is specified.
###### QUERY MODE (specifically when viewing query results)
[:d] to reset table data back to original view
[:sql] to query original database again
================================================
FILE: database/query.go
================================================
package database
import (
"database/sql"
"sync"
)
var (
DBMutex sync.Mutex
Databases map[string]*sql.DB
DriverString string
IsCSV bool
)
func init() {
// We keep one connection pool per database.
DBMutex = sync.Mutex{}
Databases = make(map[string]*sql.DB)
}
type Query interface {
GetValues() map[string]interface{}
SetValues(map[string]interface{})
}
type Database interface {
Update(q *Update)
GenerateQuery(u *Update) (string, []string)
GetPlaceholderForDatabaseType() string
GetFileName() string
GetTableNamesQuery() string
GetDatabaseReference() *sql.DB
CloseDatabaseReference()
SetDatabaseReference(dbPath string)
}
type Update struct {
v map[string]interface{} // these are anchors to ensure the right row/col gets updated
Column string // this is the header
Update interface{} // this is the new cell value
TableName string
}
func (u *Update) GetValues() map[string]interface{} {
return u.v
}
func (u *Update) SetValues(v map[string]interface{}) {
u.v = v
}
// GetDatabaseForFile does what you think it does
func GetDatabaseForFile(database string) *sql.DB {
DBMutex.Lock()
defer DBMutex.Unlock()
if db, ok := Databases[database]; ok {
return db
}
db, err := sql.Open(DriverString, database)
if err != nil {
panic(err)
}
Databases[database] = db
return db
}
func ProcessSqlQueryForDatabaseType(q Query, rowData map[string]interface{}, schemaName, columnName string, db *Database) {
switch conv := q.(type) {
case *Update:
conv.SetValues(rowData)
conv.TableName = schemaName
conv.Column = columnName
(*db).Update(conv)
break
}
}
================================================
FILE: database/sqlite.go
================================================
package database
import (
"database/sql"
"fmt"
"log"
"strings"
)
type SQLite struct {
FileName string
Database *sql.DB
}
func (db *SQLite) Update(q *Update) {
protoQuery, columnOrder := db.GenerateQuery(q)
values := make([]interface{}, len(columnOrder))
updateValues := q.GetValues()
for i, v := range columnOrder {
var u interface{}
if i == 0 {
u = q.Update
} else {
u = updateValues[v]
}
if u == nil {
u = "NULL"
}
values[i] = u
}
tx, err := db.GetDatabaseReference().Begin()
if err != nil {
log.Fatal(err)
}
stmt, err := tx.Prepare(protoQuery)
if err != nil {
log.Fatal(err)
}
defer stmt.Close()
stmt.Exec(values...)
err = tx.Commit()
if err != nil {
log.Fatal(err)
}
}
func (db *SQLite) GetFileName() string {
return db.FileName
}
func (db *SQLite) GetDatabaseReference() *sql.DB {
return db.Database
}
func (db *SQLite) CloseDatabaseReference() {
db.GetDatabaseReference().Close()
db.Database = nil
}
func (db *SQLite) SetDatabaseReference(dbPath string) {
database := GetDatabaseForFile(dbPath)
db.FileName = dbPath
db.Database = database
}
func (db SQLite) GetPlaceholderForDatabaseType() string {
return "?"
}
func (db SQLite) GetTableNamesQuery() string {
val := "SELECT name FROM "
val += "sqlite_master"
val += " WHERE type='table'"
return val
}
func (db *SQLite) GenerateQuery(u *Update) (string, []string) {
var (
query string
querySkeleton string
valueOrder []string
)
placeholder := db.GetPlaceholderForDatabaseType()
querySkeleton = fmt.Sprintf("UPDATE %s"+
" SET %s=%s ", u.TableName, u.Column, placeholder)
valueOrder = append(valueOrder, u.Column)
whereBuilder := strings.Builder{}
whereBuilder.WriteString(" WHERE ")
uLen := len(u.GetValues())
i := 0
for k := range u.GetValues() { // keep track of order since maps aren't deterministic
assertion := fmt.Sprintf("%s=%s ", k, placeholder)
valueOrder = append(valueOrder, k)
whereBuilder.WriteString(assertion)
if uLen > 1 && i < uLen-1 {
whereBuilder.WriteString("AND ")
}
i++
}
query = querySkeleton + strings.TrimSpace(whereBuilder.String()) + ";"
return query, valueOrder
}
================================================
FILE: go.mod
================================================
module github.com/mathaou/termdbms
go 1.16
require (
github.com/atotto/clipboard v0.1.2
github.com/charmbracelet/bubbles v0.9.0
github.com/charmbracelet/bubbletea v0.18.0
github.com/charmbracelet/lipgloss v0.4.0
github.com/mattn/go-isatty v0.0.14-0.20210829144114-504425e14f74 // indirect
github.com/mattn/go-runewidth v0.0.13
github.com/muesli/reflow v0.3.0
github.com/muesli/termenv v0.9.0
github.com/sahilm/fuzzy v0.1.0
modernc.org/sqlite v1.13.0
)
================================================
FILE: go.sum
================================================
github.com/atotto/clipboard v0.1.2 h1:YZCtFu5Ie8qX2VmVTBnrqLSiU9XOWwqNRmdT3gIQzbY=
github.com/atotto/clipboard v0.1.2/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/charmbracelet/bubbles v0.9.0 h1:lqJ8FXwoLceQF2J0A+dWo1Cuu1dNyjbW4Opgdi2vkhw=
github.com/charmbracelet/bubbles v0.9.0/go.mod h1:NWT/c+0rYEnYChz5qCyX4Lj6fDw9gGToh9EFJPajghU=
github.com/charmbracelet/bubbletea v0.14.1/go.mod h1:b5lOf5mLjMg1tRn1HVla54guZB+jvsyV0yYAQja95zE=
github.com/charmbracelet/bubbletea v0.18.0 h1:v9JrrWADDZ5Tk5DV8Rj3MIiqhrrk33RIGBUnYvZsQbc=
github.com/charmbracelet/bubbletea v0.18.0/go.mod h1:VuXF2pToRxDUHcBUcPmCRUHRvFATM4Ckb/ql1rBl3KA=
github.com/charmbracelet/harmonica v0.1.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/lipgloss v0.3.0/go.mod h1:VkhdBS2eNAmRkTwRKLJCFhCOVkjntMusBDxv7TXahuk=
github.com/charmbracelet/lipgloss v0.4.0 h1:768h64EFkGUr8V5yAKV7/Ta0NiVceiPaV+PphaW1K9g=
github.com/charmbracelet/lipgloss v0.4.0/go.mod h1:vmdkHvce7UzX6xkyf4cca8WlwdQ5RQr8fzta+xl7BOM=
github.com/containerd/console v1.0.1/go.mod h1:XUsP6YE/mKtz6bxc+I8UiKKTP04qjQL4qcS3XoQ5xkw=
github.com/containerd/console v1.0.2 h1:Pi6D+aZXM+oUw1czuKgH5IJ+y0jhYcwBJfx5/Ghn9dE=
github.com/containerd/console v1.0.2/go.mod h1:ytZPjGgY2oeTkAONYafi2kSj0aYggsf8acV1PGKCbzQ=
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/google/go-cmp v0.5.3 h1:x95R7cp+rSeeqAMI2knLtQ0DKlaBhv2NrtrOvafPHRo=
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
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.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.13/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.14-0.20210829144114-504425e14f74 h1:3kEhu34+VLPo2YgQ1PXHLQRgMQKtBmq+MmMYEBHGX7U=
github.com/mattn/go-isatty v0.0.14-0.20210829144114-504425e14f74/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.8 h1:gDp86IdQsN/xWjIEmr9MF6o9mpksUgh0fu+9ByFxzIU=
github.com/mattn/go-sqlite3 v1.14.8/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
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/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ=
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.8.1/go.mod h1:kzt/D/4a88RoheZmwfqorY3A+tnsSMA9HJC/fQSFKo0=
github.com/muesli/termenv v0.9.0 h1:wnbOaGz+LUR3jNT0zOzinPnyDaCZUQRZj9GxK8eRVl8=
github.com/muesli/termenv v0.9.0/go.mod h1:R/LzAKf+suGs4IsO95y7+7DpFHO0KABgnZqtlyx2mBw=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 h1:OdAsTTz6OkFY5QxjkYwrChwuRruF69c169dPK26NUlk=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI=
github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/mod v0.3.0 h1:RM4zey1++hCTbCVQfnWeKs9/IEsaBLA8vTkd0WVtmH4=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200916030750-2334cc1a136f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201126233918-771906719818/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210902050250-f475640dd07b h1:S7hKs0Flbq0bbc9xgYt4stIEG1zNDFqyrPwAX2Wj/sE=
golang.org/x/sys v0.0.0-20210902050250-f475640dd07b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20210422114643-f5beecf764ed h1:Ei4bQjjpYUsS4efOUz+5Nz++IVkHk87n2zBA0NxBWc0=
golang.org/x/term v0.0.0-20210422114643-f5beecf764ed/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
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/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.0.0-20201124115921-2c860bdd6e78 h1:M8tBwCtWD/cZV9DZpFYRUgaymAYAr+aIUTWzDaM3uPs=
golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
lukechampine.com/uint128 v1.1.1 h1:pnxCASz787iMf+02ssImqk6OLt+Z5QHMoZyUXR4z6JU=
lukechampine.com/uint128 v1.1.1/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
modernc.org/cc/v3 v3.33.6/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g=
modernc.org/cc/v3 v3.33.9/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g=
modernc.org/cc/v3 v3.33.11/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g=
modernc.org/cc/v3 v3.34.0 h1:dFhZc/HKR3qp92sYQxKRRaDMz+sr1bwcFD+m7LSCrAs=
modernc.org/cc/v3 v3.34.0/go.mod h1:iPJg1pkwXqAV16SNgFBVYmggfMg6xhs+2oiO0vclK3g=
modernc.org/ccgo/v3 v3.9.5/go.mod h1:umuo2EP2oDSBnD3ckjaVUXMrmeAw8C8OSICVa0iFf60=
modernc.org/ccgo/v3 v3.10.0/go.mod h1:c0yBmkRFi7uW4J7fwx/JiijwOjeAeR2NoSaRVFPmjMw=
modernc.org/ccgo/v3 v3.11.0/go.mod h1:dGNposbDp9TOZ/1KBxghxtUp/bzErD0/0QW4hhSaBMI=
modernc.org/ccgo/v3 v3.11.1/go.mod h1:lWHxfsn13L3f7hgGsGlU28D9eUOf6y3ZYHKoPaKU0ag=
modernc.org/ccgo/v3 v3.11.2 h1:gqa8PQ2v7SjrhHCgxUO5dzoAJWSLAveJqZTNkPCN0kc=
modernc.org/ccgo/v3 v3.11.2/go.mod h1:6kii3AptTDI+nUrM9RFBoIEUEisSWCbdczD9ZwQH2FE=
modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM=
modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM=
modernc.org/libc v1.9.8/go.mod h1:U1eq8YWr/Kc1RWCMFUWEdkTg8OTcfLw2kY8EDwl039w=
modernc.org/libc v1.9.11/go.mod h1:NyF3tsA5ArIjJ83XB0JlqhjTabTCHm9aX4XMPHyQn0Q=
modernc.org/libc v1.11.0/go.mod h1:2lOfPmj7cz+g1MrPNmX65QCzVxgNq2C5o0jdLY2gAYg=
modernc.org/libc v1.11.2/go.mod h1:ioIyrl3ETkugDO3SGZ+6EOKvlP3zSOycUETe4XM4n8M=
modernc.org/libc v1.11.3 h1:q//spBhqp23lC/if8/o8hlyET57P8mCZqrqftzT2WmY=
modernc.org/libc v1.11.3/go.mod h1:k3HDCP95A6U111Q5TmG3nAyUcp3kR5YFZTeDS9v8vSU=
modernc.org/mathutil v1.1.1/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/mathutil v1.2.2/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/mathutil v1.4.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/mathutil v1.4.1 h1:ij3fYGe8zBF4Vu+g0oT7mB06r8sqGWKuJu1yXeR4by8=
modernc.org/mathutil v1.4.1/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.0.4/go.mod h1:nV2OApxradM3/OVbs2/0OsP6nPfakXpi50C7dcoHXlc=
modernc.org/memory v1.0.5 h1:XRch8trV7GgvTec2i7jc33YlUI0RKVDBvZ5eZ5m8y14=
modernc.org/memory v1.0.5/go.mod h1:B7OYswTRnfGg+4tDH1t1OeUNnsy2viGTdME4tzd+IjM=
modernc.org/opt v0.1.1 h1:/0RX92k9vwVeDXj+Xn23DKp2VJubL7k8qNffND6qn3A=
modernc.org/opt v0.1.1/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sqlite v1.13.0 h1:cwhUj0jTBgPjk/demWheV+T6xi6ifTfsGIFKFq0g3Ck=
modernc.org/sqlite v1.13.0/go.mod h1:2qO/6jZJrcQaxFUHxOwa6Q6WfiGSsiVj6GXX0Ker+Jg=
modernc.org/strutil v1.1.1 h1:xv+J1BXY3Opl2ALrBwyfEikFAj8pmqcpnfmuwUwcozs=
modernc.org/strutil v1.1.1/go.mod h1:DE+MQQ/hjKBZS2zNInV5hhcipt5rLPWkmpbGeW5mmdw=
modernc.org/tcl v1.5.9 h1:DZMfR+RDJRhcrmMEMTJgVIX+Wf5qhfVX0llI0rsc20w=
modernc.org/tcl v1.5.9/go.mod h1:bcwjvBJ2u0exY6K35eAmxXBBij5kXb1dHlAWmfhqThE=
modernc.org/token v1.0.0 h1:a0jaWiNMDhDUtqOj09wvjWWAqd3q7WpBulmL9H2egsk=
modernc.org/token v1.0.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
modernc.org/z v1.1.2 h1:IjjzDsIFbl0wuF2KfwvdyUAJVwxD4iwZ6akLNiDoClM=
modernc.org/z v1.1.2/go.mod h1:sj9T1AGBG0dm6SCVzldPOHWrif6XBpooJtbttMn1+Js=
================================================
FILE: list/defaultitem.go
================================================
package list
import (
"fmt"
"io"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/muesli/reflow/truncate"
)
// DefaultItemStyles defines styling for a default list item.
// See DefaultItemView for when these come into play.
type DefaultItemStyles struct {
// The Normal state.
NormalTitle lipgloss.Style
NormalDesc lipgloss.Style
// The selected item state.
SelectedTitle lipgloss.Style
SelectedDesc lipgloss.Style
// The dimmed state, for when the filter input is initially activated.
DimmedTitle lipgloss.Style
DimmedDesc lipgloss.Style
// Charcters matching the current filter, if any.
FilterMatch lipgloss.Style
}
// NewDefaultItemStyles returns style definitions for a default item. See
// DefaultItemView for when these come into play.
func NewDefaultItemStyles() (s DefaultItemStyles) {
s.NormalTitle = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#1a1a1a", Dark: "#dddddd"}).
Padding(0, 0, 0, 2)
s.NormalDesc = s.NormalTitle.Copy().
Foreground(lipgloss.AdaptiveColor{Light: "#A49FA5", Dark: "#777777"})
s.SelectedTitle = lipgloss.NewStyle().
Border(lipgloss.NormalBorder(), false, false, false, true).
BorderForeground(lipgloss.AdaptiveColor{Light: "#F793FF", Dark: "#AD58B4"}).
Foreground(lipgloss.AdaptiveColor{Light: "#EE6FF8", Dark: "#EE6FF8"}).
Padding(0, 0, 0, 1)
s.SelectedDesc = s.SelectedTitle.Copy().
Foreground(lipgloss.AdaptiveColor{Light: "#F793FF", Dark: "#AD58B4"})
s.DimmedTitle = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#A49FA5", Dark: "#777777"}).
Padding(0, 0, 0, 2)
s.DimmedDesc = s.DimmedTitle.Copy().
Foreground(lipgloss.AdaptiveColor{Light: "#C2B8C2", Dark: "#4D4D4D"})
s.FilterMatch = lipgloss.NewStyle().Underline(true)
return s
}
// DefaultItem describes an items designed to work with DefaultDelegate.
type DefaultItem interface {
Item
Title() string
Description() string
}
// DefaultDelegate is a standard delegate designed to work in lists. It's
// styled by DefaultItemStyles, which can be customized as you like.
//
// The description line can be hidden by setting Description to false, which
// renders the list as single-line-items. The spacing between items can be set
// with the SetSpacing method.
//
// Setting UpdateFunc is optional. If it's set it will be called when the
// ItemDelegate called, which is called when the list's Update function is
// invoked.
//
// Settings ShortHelpFunc and FullHelpFunc is optional. They can can be set to
// include items in the list's default short and full help menus.
type DefaultDelegate struct {
ShowDescription bool
Styles DefaultItemStyles
UpdateFunc func(tea.Msg, *Model) tea.Cmd
ShortHelpFunc func() []key.Binding
FullHelpFunc func() [][]key.Binding
spacing int
}
// NewDefaultDelegate creates a new delegate with default styles.
func NewDefaultDelegate() DefaultDelegate {
return DefaultDelegate{
ShowDescription: true,
Styles: NewDefaultItemStyles(),
spacing: 1,
}
}
// Height returns the delegate's preferred height.
func (d DefaultDelegate) Height() int {
if d.ShowDescription {
return 2 //nolint:gomnd
}
return 1
}
// SetSpacing set the delegate's spacing.
func (d *DefaultDelegate) SetSpacing(i int) {
d.spacing = i
}
// Spacing returns the delegate's spacing.
func (d DefaultDelegate) Spacing() int {
return d.spacing
}
// Update checks whether the delegate's UpdateFunc is set and calls it.
func (d DefaultDelegate) Update(msg tea.Msg, m *Model) tea.Cmd {
if d.UpdateFunc == nil {
return nil
}
return d.UpdateFunc(msg, m)
}
// Render prints an item.
func (d DefaultDelegate) Render(w io.Writer, m Model, index int, item Item) {
var (
title, desc string
matchedRunes []int
s = &d.Styles
)
if i, ok := item.(DefaultItem); ok {
title = i.Title()
desc = i.Description()
} else {
return
}
// Prevent text from exceeding list width
if m.width > 0 {
textwidth := uint(m.width - s.NormalTitle.GetPaddingLeft() - s.NormalTitle.GetPaddingRight())
title = truncate.StringWithTail(title, textwidth, ellipsis)
desc = truncate.StringWithTail(desc, textwidth, ellipsis)
}
// Conditions
var (
isSelected = index == m.Index()
emptyFilter = m.FilterState() == Filtering && m.FilterValue() == ""
isFiltered = m.FilterState() == Filtering || m.FilterState() == FilterApplied
)
if isFiltered && index < len(m.filteredItems) {
// Get indices of matched characters
matchedRunes = m.MatchesForItem(index)
}
if emptyFilter {
title = s.DimmedTitle.Render(title)
desc = s.DimmedDesc.Render(desc)
} else if isSelected && m.FilterState() != Filtering {
if isFiltered {
// Highlight matches
unmatched := s.SelectedTitle.Inline(true)
matched := unmatched.Copy().Inherit(s.FilterMatch)
title = lipgloss.StyleRunes(title, matchedRunes, matched, unmatched)
}
title = s.SelectedTitle.Render(title)
desc = s.SelectedDesc.Render(desc)
} else {
if isFiltered {
// Highlight matches
unmatched := s.NormalTitle.Inline(true)
matched := unmatched.Copy().Inherit(s.FilterMatch)
title = lipgloss.StyleRunes(title, matchedRunes, matched, unmatched)
}
title = s.NormalTitle.Render(title)
desc = s.NormalDesc.Render(desc)
}
if d.ShowDescription {
fmt.Fprintf(w, "%s\n%s", title, desc)
return
}
fmt.Fprintf(w, "%s", title)
}
// ShortHelp returns the delegate's short help.
func (d DefaultDelegate) ShortHelp() []key.Binding {
if d.ShortHelpFunc != nil {
return d.ShortHelpFunc()
}
return nil
}
// FullHelp returns the delegate's full help.
func (d DefaultDelegate) FullHelp() [][]key.Binding {
if d.FullHelpFunc != nil {
return d.FullHelpFunc()
}
return nil
}
================================================
FILE: list/keys.go
================================================
package list
import "github.com/charmbracelet/bubbles/key"
// KeyMap defines keybindings. It satisfies to the help.KeyMap interface, which
// is used to render the menu menu.
type KeyMap struct {
// Keybindings used when browsing the list.
CursorUp key.Binding
CursorDown key.Binding
NextPage key.Binding
PrevPage key.Binding
GoToStart key.Binding
GoToEnd key.Binding
Filter key.Binding
ClearFilter key.Binding
DeleteSelection key.Binding
// Keybindings used when setting a filter.
CancelWhileFiltering key.Binding
AcceptWhileFiltering key.Binding
// Help toggle keybindings.
ShowFullHelp key.Binding
CloseFullHelp key.Binding
// The quit keybinding. This won't be caught when filtering.
Quit key.Binding
// The quit-no-matter-what keybinding. This will be caught when filtering.
ForceQuit key.Binding
}
// DefaultKeyMap returns a default set of keybindings.
func DefaultKeyMap() KeyMap {
return KeyMap{
DeleteSelection: key.NewBinding(
key.WithKeys("r"),
key.WithHelp("r", "remove selection")),
// Browsing.
CursorUp: key.NewBinding(
key.WithKeys("up", "k", "w"),
key.WithHelp("↑/k", "up"),
),
CursorDown: key.NewBinding(
key.WithKeys("down", "j", "s"),
key.WithHelp("↓/j", "down"),
),
PrevPage: key.NewBinding(
key.WithKeys("left", "h", "pgup", "b", "u", "a"),
key.WithHelp("←/h/pgup", "prev page"),
),
NextPage: key.NewBinding(
key.WithKeys("right", "l", "pgdown", "f", "d"),
key.WithHelp("→/l/pgdn", "next page"),
),
GoToStart: key.NewBinding(
key.WithKeys("home", "g"),
key.WithHelp("g/home", "go to start"),
),
GoToEnd: key.NewBinding(
key.WithKeys("end", "G"),
key.WithHelp("G/end", "go to end"),
),
Filter: key.NewBinding(
key.WithKeys("/"),
key.WithHelp("/", "filter"),
),
ClearFilter: key.NewBinding(
key.WithKeys("esc"),
key.WithHelp("esc", "clear filter"),
),
// Filtering.
CancelWhileFiltering: key.NewBinding(
key.WithKeys("esc"),
key.WithHelp("esc", "cancel"),
),
AcceptWhileFiltering: key.NewBinding(
key.WithKeys("enter", "tab", "shift+tab", "ctrl+k", "up", "ctrl+j", "down"),
key.WithHelp("enter", "apply filter"),
),
// Toggle help.
ShowFullHelp: key.NewBinding(
key.WithKeys("?"),
key.WithHelp("?", "more"),
),
CloseFullHelp: key.NewBinding(
key.WithKeys("?"),
key.WithHelp("?", "close help"),
),
// Quitting.
Quit: key.NewBinding(
key.WithKeys("q", "esc"),
key.WithHelp("q", "back"),
),
ForceQuit: key.NewBinding(key.WithKeys("ctrl+c")),
}
}
================================================
FILE: list/list.go
================================================
// Package list provides a feature-rich Bubble Tea component for browsing
// a general purpose list of items. It features optional filtering, pagination,
// help, status messages, and a spinner to indicate activity.
package list
import (
"fmt"
"io"
"sort"
"strings"
"time"
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/paginator"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/muesli/reflow/ansi"
"github.com/muesli/reflow/truncate"
"github.com/sahilm/fuzzy"
)
// Item is an item that appears in the list.
type Item interface {
// Filter value is the value we use when filtering against this item when
// we're filtering the list.
FilterValue() string
}
// ItemDelegate encapsulates the general functionality for all list items. The
// benefit to separating this logic from the item itself is that you can change
// the functionality of items without changing the actual items themselves.
//
// Note that if the delegate also implements help.KeyMap delegate-related
// help items will be added to the help view.
type ItemDelegate interface {
// Render renders the item's view.
Render(w io.Writer, m Model, index int, item Item)
// Height is the height of the list item.
Height() int
// Spacing is the size of the horizontal gap between list items in cells.
Spacing() int
// Update is the update loop for items. All messages in the list's update
// loop will pass through here except when the user is setting a filter.
// Use this method to perform item-level updates appropriate to this
// delegate.
Update(msg tea.Msg, m *Model) tea.Cmd
}
type filteredItem struct {
item Item // item matched
matches []int // rune indices of matched items
}
type filteredItems []filteredItem
func (f filteredItems) items() []Item {
agg := make([]Item, len(f))
for i, v := range f {
agg[i] = v.item
}
return agg
}
func (f filteredItems) matches() [][]int {
agg := make([][]int, len(f))
for i, v := range f {
agg[i] = v.matches
}
return agg
}
type FilterMatchesMessage []filteredItem
type statusMessageTimeoutMsg struct{}
// FilterState describes the current filtering state on the model.
type FilterState int
// Possible filter states.
const (
Unfiltered FilterState = iota // no filter set
Filtering // user is actively setting a filter
FilterApplied // a filter is applied and user is not editing filter
)
// String returns a human-readable string of the current filter state.
func (f FilterState) String() string {
return [...]string{
"unfiltered",
"filtering",
"filter applied",
}[f]
}
// Model contains the state of this component.
type Model struct {
showTitle bool
showFilter bool
showStatusBar bool
showPagination bool
showHelp bool
filteringEnabled bool
Title string
Styles Styles
// Key mappings for navigating the list.
KeyMap KeyMap
// Additional key mappings for the short and full help views. This allows
// you to add additional key mappings to the help menu without
// re-implementing the help component. Of course, you can also disable the
// list's help component and implement a new one if you need more
// flexibility.
AdditionalShortHelpKeys func() []key.Binding
AdditionalFullHelpKeys func() []key.Binding
spinner spinner.Model
showSpinner bool
width int
height int
Paginator paginator.Model
cursor int
Help help.Model
FilterInput textinput.Model
filterState FilterState
// How long status messages should stay visible. By default this is
// 1 second.
StatusMessageLifetime time.Duration
statusMessage string
statusMessageTimer *time.Timer
// The master set of items we're working with.
items []Item
// Filtered items we're currently displaying. Filtering, toggles and so on
// will alter this slice so we can show what is relevant. For that reason,
// this field should be considered ephemeral.
filteredItems filteredItems
delegate ItemDelegate
}
// NewModel returns a new model with sensible defaults.
func NewModel(items []Item, delegate ItemDelegate, width, height int) Model {
styles := DefaultStyles()
sp := spinner.NewModel()
sp.Spinner = spinner.Line
sp.Style = styles.Spinner
filterInput := textinput.NewModel()
filterInput.Prompt = "Filter: "
filterInput.PromptStyle = styles.FilterPrompt
filterInput.CursorStyle = styles.FilterCursor
filterInput.CharLimit = 64
filterInput.Focus()
p := paginator.NewModel()
p.Type = paginator.Dots
p.ActiveDot = styles.ActivePaginationDot.String()
p.InactiveDot = styles.InactivePaginationDot.String()
m := Model{
showTitle: true,
showFilter: true,
showStatusBar: true,
showPagination: true,
showHelp: true,
filteringEnabled: true,
KeyMap: DefaultKeyMap(),
Styles: styles,
Title: "List",
FilterInput: filterInput,
StatusMessageLifetime: time.Second,
width: width,
height: height,
delegate: delegate,
items: items,
Paginator: p,
spinner: sp,
Help: help.NewModel(),
}
m.updatePagination()
m.updateKeybindings()
return m
}
// SetFilteringEnabled enables or disables filtering. Note that this is different
// from ShowFilter, which merely hides or shows the input view.
func (m *Model) SetFilteringEnabled(v bool) {
m.filteringEnabled = v
if !v {
m.resetFiltering()
}
m.updateKeybindings()
}
// FilteringEnabled returns whether or not filtering is enabled.
func (m Model) FilteringEnabled() bool {
return m.filteringEnabled
}
// SetShowTitle shows or hides the title bar.
func (m *Model) SetShowTitle(v bool) {
m.showTitle = v
m.updatePagination()
}
// ShowTitle returns whether or not the title bar is set to be rendered.
func (m Model) ShowTitle() bool {
return m.showTitle
}
// SetShowFilter shows or hides the filer bar. Note that this does not disable
// filtering, it simply hides the built-in filter view. This allows you to
// use the FilterInput to render the filtering UI differently without having to
// re-implement filtering from scratch.
//
// To disable filtering entirely use EnableFiltering.
func (m *Model) SetShowFilter(v bool) {
m.showFilter = v
m.updatePagination()
}
// ShowFilter returns whether or not the filter is set to be rendered. Note
// that this is separate from FilteringEnabled, so filtering can be hidden yet
// still invoked. This allows you to render filtering differently without
// having to re-implement it from scratch.
func (m Model) ShowFilter() bool {
return m.showFilter
}
// SetShowStatusBar shows or hides the view that displays metadata about the
// list, such as item counts.
func (m *Model) SetShowStatusBar(v bool) {
m.showStatusBar = v
m.updatePagination()
}
// ShowStatusBar returns whether or not the status bar is set to be rendered.
func (m Model) ShowStatusBar() bool {
return m.showStatusBar
}
// ShowingPagination hides or shoes the paginator. Note that pagination will
// still be active, it simply won't be displayed.
func (m *Model) SetShowPagination(v bool) {
m.showPagination = v
m.updatePagination()
}
// ShowPagination returns whether the pagination is visible.
func (m *Model) ShowPagination() bool {
return m.showPagination
}
// SetShowHelp shows or hides the help view.
func (m *Model) SetShowHelp(v bool) {
m.showHelp = v
m.updatePagination()
}
// ShowHelp returns whether or not the help is set to be rendered.
func (m Model) ShowHelp() bool {
return m.showHelp
}
// Items returns the items in the list.
func (m Model) Items() []Item {
return m.items
}
// Set the items available in the list. This returns a command.
func (m *Model) SetItems(i []Item) tea.Cmd {
var cmd tea.Cmd
m.items = i
if m.filterState != Unfiltered {
m.filteredItems = nil
cmd = filterItems(*m)
}
m.updatePagination()
return cmd
}
// Select selects the given index of the list and goes to its respective page.
func (m *Model) Select(index int) {
m.Paginator.Page = index / m.Paginator.PerPage
m.cursor = index % m.Paginator.PerPage
}
// ResetSelected resets the selected item to the first item in the first page of the list.
func (m *Model) ResetSelected() {
m.Select(0)
}
// ResetFilter resets the current filtering state.
func (m *Model) ResetFilter() {
m.resetFiltering()
}
// Replace an item at the given index. This returns a command.
func (m *Model) SetItem(index int, item Item) tea.Cmd {
var cmd tea.Cmd
m.items[index] = item
if m.filterState != Unfiltered {
cmd = filterItems(*m)
}
m.updatePagination()
return cmd
}
// Insert an item at the given index. This returns a command.
func (m *Model) InsertItem(index int, item Item) tea.Cmd {
var cmd tea.Cmd
m.items = insertItemIntoSlice(m.items, item, index)
if m.filterState != Unfiltered {
cmd = filterItems(*m)
}
m.updatePagination()
return cmd
}
// RemoveItem removes an item at the given index. If the index is out of bounds
// this will be a no-op. O(n) complexity, which probably won't matter in the
// case of a TUI.
func (m *Model) RemoveItem(index int) {
m.items = removeItemFromSlice(m.items, index)
if m.filterState != Unfiltered {
m.filteredItems = removeFilterMatchFromSlice(m.filteredItems, index)
if len(m.filteredItems) == 0 {
m.resetFiltering()
}
}
m.updatePagination()
}
// Set the item delegate.
func (m *Model) SetDelegate(d ItemDelegate) {
m.delegate = d
m.updatePagination()
}
// VisibleItems returns the total items available to be shown.
func (m Model) VisibleItems() []Item {
if m.filterState != Unfiltered {
return m.filteredItems.items()
}
return m.items
}
// SelectedItems returns the current selected item in the list.
func (m Model) SelectedItem() Item {
i := m.Index()
items := m.VisibleItems()
if i < 0 || len(items) == 0 || len(items) <= i {
return nil
}
return items[i]
}
// MatchesForItem returns rune positions matched by the current filter, if any.
// Use this to style runes matched by the active filter.
//
// See DefaultItemView for a usage example.
func (m Model) MatchesForItem(index int) []int {
if m.filteredItems == nil || index >= len(m.filteredItems) {
return nil
}
return m.filteredItems[index].matches
}
// Index returns the index of the currently selected item as it appears in the
// entire slice of items.
func (m Model) Index() int {
return m.Paginator.Page*m.Paginator.PerPage + m.cursor
}
// Cursor returns the index of the cursor on the current page.
func (m Model) Cursor() int {
return m.cursor
}
// CursorUp moves the cursor up. This can also move the state to the previous
// page.
func (m *Model) CursorUp() {
m.cursor--
// If we're at the start, stop
if m.cursor < 0 && m.Paginator.Page == 0 {
m.cursor = 0
return
}
// Move the cursor as normal
if m.cursor >= 0 {
return
}
// Go to the previous page
m.Paginator.PrevPage()
m.cursor = m.Paginator.ItemsOnPage(len(m.VisibleItems())) - 1
}
// CursorDown moves the cursor down. This can also advance the state to the
// next page.
func (m *Model) CursorDown() {
itemsOnPage := m.Paginator.ItemsOnPage(len(m.VisibleItems()))
m.cursor++
// If we're at the end, stop
if m.cursor < itemsOnPage {
return
}
// Go to the next page
if !m.Paginator.OnLastPage() {
m.Paginator.NextPage()
m.cursor = 0
return
}
// During filtering the cursor position can exceed the number of
// itemsOnPage. It's more intuitive to start the cursor at the
// topmost position when moving it down in this scenario.
if m.cursor > itemsOnPage {
m.cursor = 0
return
}
m.cursor = itemsOnPage - 1
}
// PrevPage moves to the previous page, if available.
func (m Model) PrevPage() {
m.Paginator.PrevPage()
}
// NextPage moves to the next page, if available.
func (m Model) NextPage() {
m.Paginator.NextPage()
}
// FilterState returns the current filter state.
func (m Model) FilterState() FilterState {
return m.filterState
}
// FilterValue returns the current value of the filter.
func (m Model) FilterValue() string {
return m.FilterInput.Value()
}
// SettingFilter returns whether or not the user is currently editing the
// filter value. It's purely a convenience method for the following:
//
// m.FilterState() == Filtering
//
// It's included here because it's a common thing to check for when
// implementing this component.
func (m Model) SettingFilter() bool {
return m.filterState == Filtering
}
// Width returns the current width setting.
func (m Model) Width() int {
return m.width
}
// Height returns the current height setting.
func (m Model) Height() int {
return m.height
}
// SetSpinner allows to set the spinner style.
func (m *Model) SetSpinner(spinner spinner.Spinner) {
m.spinner.Spinner = spinner
}
// Toggle the spinner. Note that this also returns a command.
func (m *Model) ToggleSpinner() tea.Cmd {
if !m.showSpinner {
return m.StartSpinner()
}
m.StopSpinner()
return nil
}
// StartSpinner starts the spinner. Note that this returns a command.
func (m *Model) StartSpinner() tea.Cmd {
m.showSpinner = true
return spinner.Tick
}
// StopSpinner stops the spinner.
func (m *Model) StopSpinner() {
m.showSpinner = false
}
// Helper for disabling the keybindings used for quitting, incase you want to
// handle this elsewhere in your application.
func (m *Model) DisableQuitKeybindings() {
m.KeyMap.Quit.SetEnabled(false)
m.KeyMap.ForceQuit.SetEnabled(false)
}
// NewStatusMessage sets a new status message, which will show for a limited
// amount of time. Note that this also returns a command.
func (m *Model) NewStatusMessage(s string) tea.Cmd {
m.statusMessage = s
if m.statusMessageTimer != nil {
m.statusMessageTimer.Stop()
}
m.statusMessageTimer = time.NewTimer(m.StatusMessageLifetime)
// Wait for timeout
return func() tea.Msg {
<-m.statusMessageTimer.C
return statusMessageTimeoutMsg{}
}
}
// SetSize sets the width and height of this component.
func (m *Model) SetSize(width, height int) {
m.setSize(width, height)
}
// SetWidth sets the width of this component.
func (m *Model) SetWidth(v int) {
m.setSize(v, m.height)
}
// SetHeight sets the height of this component.
func (m *Model) SetHeight(v int) {
m.setSize(m.width, v)
}
func (m *Model) setSize(width, height int) {
promptWidth := lipgloss.Width(m.Styles.Title.Render(m.FilterInput.Prompt))
m.width = width
m.height = height
m.Help.Width = width
m.FilterInput.Width = width - promptWidth - lipgloss.Width(m.spinnerView())
m.updatePagination()
}
func (m *Model) resetFiltering() {
if m.filterState == Unfiltered {
return
}
m.filterState = Unfiltered
m.FilterInput.Reset()
m.filteredItems = nil
m.updatePagination()
m.updateKeybindings()
}
func (m Model) itemsAsFilterItems() filteredItems {
fi := make([]filteredItem, len(m.items))
for i, item := range m.items {
fi[i] = filteredItem{
item: item,
}
}
return fi
}
// Set keybindings according to the filter state.
func (m *Model) updateKeybindings() {
switch m.filterState {
case Filtering:
m.KeyMap.DeleteSelection.SetEnabled(false)
m.KeyMap.CursorUp.SetEnabled(false)
m.KeyMap.CursorDown.SetEnabled(false)
m.KeyMap.NextPage.SetEnabled(false)
m.KeyMap.PrevPage.SetEnabled(false)
m.KeyMap.GoToStart.SetEnabled(false)
m.KeyMap.GoToEnd.SetEnabled(false)
m.KeyMap.Filter.SetEnabled(false)
m.KeyMap.ClearFilter.SetEnabled(false)
m.KeyMap.CancelWhileFiltering.SetEnabled(true)
m.KeyMap.AcceptWhileFiltering.SetEnabled(m.FilterInput.Value() != "")
m.KeyMap.Quit.SetEnabled(true)
m.KeyMap.ShowFullHelp.SetEnabled(false)
m.KeyMap.CloseFullHelp.SetEnabled(false)
default:
m.KeyMap.DeleteSelection.SetEnabled(true)
hasItems := m.items != nil
m.KeyMap.CursorUp.SetEnabled(hasItems)
m.KeyMap.CursorDown.SetEnabled(hasItems)
hasPages := m.Paginator.TotalPages > 1
m.KeyMap.NextPage.SetEnabled(hasPages)
m.KeyMap.PrevPage.SetEnabled(hasPages)
m.KeyMap.GoToStart.SetEnabled(hasItems)
m.KeyMap.GoToEnd.SetEnabled(hasItems)
m.KeyMap.Filter.SetEnabled(m.filteringEnabled && hasItems)
m.KeyMap.ClearFilter.SetEnabled(m.filterState == FilterApplied)
m.KeyMap.CancelWhileFiltering.SetEnabled(false)
m.KeyMap.AcceptWhileFiltering.SetEnabled(false)
m.KeyMap.Quit.SetEnabled(true)
if m.Help.ShowAll {
m.KeyMap.ShowFullHelp.SetEnabled(true)
m.KeyMap.CloseFullHelp.SetEnabled(true)
} else {
minHelp := countEnabledBindings(m.FullHelp()) > 1
m.KeyMap.ShowFullHelp.SetEnabled(minHelp)
m.KeyMap.CloseFullHelp.SetEnabled(minHelp)
}
}
}
// Update pagination according to the amount of items for the current state.
func (m *Model) updatePagination() {
index := m.Index()
availHeight := m.height
if m.showTitle || (m.showFilter && m.filteringEnabled) {
availHeight -= lipgloss.Height(m.titleView())
}
if m.showStatusBar {
availHeight -= lipgloss.Height(m.statusView())
}
if m.showPagination {
availHeight -= lipgloss.Height(m.paginationView())
}
if m.showHelp {
availHeight -= lipgloss.Height(m.helpView())
}
m.Paginator.PerPage = max(1, availHeight/(m.delegate.Height()+m.delegate.Spacing()))
if pages := len(m.VisibleItems()); pages < 1 {
m.Paginator.SetTotalPages(1)
} else {
m.Paginator.SetTotalPages(pages)
}
// Restore index
m.Paginator.Page = index / m.Paginator.PerPage
m.cursor = index % m.Paginator.PerPage
// Make sure the page stays in bounds
if m.Paginator.Page >= m.Paginator.TotalPages-1 {
m.Paginator.Page = max(0, m.Paginator.TotalPages-1)
}
}
func (m *Model) hideStatusMessage() {
m.statusMessage = ""
if m.statusMessageTimer != nil {
m.statusMessageTimer.Stop()
}
}
// Update is the Bubble Tea update loop.
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
if key.Matches(msg, m.KeyMap.ForceQuit) {
return m, tea.Quit
}
case FilterMatchesMessage:
m.filteredItems = filteredItems(msg)
return m, nil
case spinner.TickMsg:
newSpinnerModel, cmd := m.spinner.Update(msg)
m.spinner = newSpinnerModel
if m.showSpinner {
cmds = append(cmds, cmd)
}
case statusMessageTimeoutMsg:
m.hideStatusMessage()
}
if m.filterState == Filtering {
cmds = append(cmds, m.handleFiltering(msg))
} else {
cmds = append(cmds, m.handleBrowsing(msg))
}
return m, tea.Batch(cmds...)
}
var (
deleteToggleSwich bool
)
// Updates for when a user is browsing the list.
func (m *Model) handleBrowsing(msg tea.Msg) tea.Cmd {
var cmds []tea.Cmd
wasDeleteSelection := false
numItems := len(m.VisibleItems())
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, m.KeyMap.DeleteSelection):
if len(m.Items()) == 0 {
break
}
wasDeleteSelection = true
if deleteToggleSwich {
m.RemoveItem(m.Cursor())
m.KeyMap.DeleteSelection.SetHelp("r", "remove selection")
} else {
m.KeyMap.DeleteSelection.SetHelp("r", "confirm selection removal")
}
deleteToggleSwich = !deleteToggleSwich
// Note: we match clear filter before quit because, by default, they're
// both mapped to escape.
case key.Matches(msg, m.KeyMap.ClearFilter):
m.resetFiltering()
case key.Matches(msg, m.KeyMap.Quit):
return tea.Quit
case key.Matches(msg, m.KeyMap.CursorUp):
m.CursorUp()
case key.Matches(msg, m.KeyMap.CursorDown):
m.CursorDown()
case key.Matches(msg, m.KeyMap.PrevPage):
m.Paginator.PrevPage()
case key.Matches(msg, m.KeyMap.NextPage):
m.Paginator.NextPage()
case key.Matches(msg, m.KeyMap.GoToStart):
m.Paginator.Page = 0
m.cursor = 0
case key.Matches(msg, m.KeyMap.GoToEnd):
m.Paginator.Page = m.Paginator.TotalPages - 1
m.cursor = m.Paginator.ItemsOnPage(numItems) - 1
case key.Matches(msg, m.KeyMap.Filter):
m.hideStatusMessage()
if m.FilterInput.Value() == "" {
// Populate filter with all items only if the filter is empty.
m.filteredItems = m.itemsAsFilterItems()
}
m.Paginator.Page = 0
m.cursor = 0
m.filterState = Filtering
m.FilterInput.CursorEnd()
m.FilterInput.Focus()
m.updateKeybindings()
return textinput.Blink
case key.Matches(msg, m.KeyMap.ShowFullHelp):
fallthrough
case key.Matches(msg, m.KeyMap.CloseFullHelp):
m.Help.ShowAll = !m.Help.ShowAll
m.updatePagination()
}
}
if !wasDeleteSelection { // if anything else reset switch
deleteToggleSwich = false
}
cmd := m.delegate.Update(msg, m)
cmds = append(cmds, cmd)
// Keep the index in bounds when paginating
itemsOnPage := m.Paginator.ItemsOnPage(len(m.VisibleItems()))
if m.cursor > itemsOnPage-1 {
m.cursor = max(0, itemsOnPage-1)
}
return tea.Batch(cmds...)
}
// Updates for when a user is in the filter editing interface.
func (m *Model) handleFiltering(msg tea.Msg) tea.Cmd {
var cmds []tea.Cmd
// Handle keys
if msg, ok := msg.(tea.KeyMsg); ok {
switch {
case key.Matches(msg, m.KeyMap.CancelWhileFiltering):
m.resetFiltering()
m.KeyMap.Filter.SetEnabled(true)
m.KeyMap.ClearFilter.SetEnabled(false)
case key.Matches(msg, m.KeyMap.AcceptWhileFiltering):
m.hideStatusMessage()
if len(m.items) == 0 {
break
}
h := m.VisibleItems()
// If we've filtered down to nothing, clear the filter
if len(h) == 0 {
m.resetFiltering()
break
}
m.FilterInput.Blur()
m.filterState = FilterApplied
m.updateKeybindings()
if m.FilterInput.Value() == "" {
m.resetFiltering()
}
}
}
// Update the filter text input component
newFilterInputModel, inputCmd := m.FilterInput.Update(msg)
filterChanged := m.FilterInput.Value() != newFilterInputModel.Value()
m.FilterInput = newFilterInputModel
cmds = append(cmds, inputCmd)
// If the filtering input has changed, request updated filtering
if filterChanged {
cmds = append(cmds, filterItems(*m))
m.KeyMap.AcceptWhileFiltering.SetEnabled(m.FilterInput.Value() != "")
}
// Update pagination
m.updatePagination()
return tea.Batch(cmds...)
}
// ShortHelp returns bindings to show in the abbreviated help view. It's part
// of the help.KeyMap interface.
func (m Model) ShortHelp() []key.Binding {
kb := []key.Binding{
m.KeyMap.CursorUp,
m.KeyMap.CursorDown,
}
filtering := m.filterState == Filtering
// If the delegate implements the help.KeyMap interface add the short help
// items to the short help after the cursor movement keys.
if !filtering {
if b, ok := m.delegate.(help.KeyMap); ok {
kb = append(kb, b.ShortHelp()...)
}
}
kb = append(kb,
m.KeyMap.Filter,
m.KeyMap.ClearFilter,
m.KeyMap.AcceptWhileFiltering,
m.KeyMap.CancelWhileFiltering,
m.KeyMap.DeleteSelection,
)
if !filtering && m.AdditionalShortHelpKeys != nil {
kb = append(kb, m.AdditionalShortHelpKeys()...)
}
return append(kb,
m.KeyMap.Quit,
m.KeyMap.ShowFullHelp,
)
}
// FullHelp returns bindings to show the full help view. It's part of the
// help.KeyMap interface.
func (m Model) FullHelp() [][]key.Binding {
kb := [][]key.Binding{{
m.KeyMap.CursorUp,
m.KeyMap.CursorDown,
m.KeyMap.NextPage,
m.KeyMap.PrevPage,
m.KeyMap.GoToStart,
m.KeyMap.GoToEnd,
}}
filtering := m.filterState == Filtering
// If the delegate implements the help.KeyMap interface add full help
// keybindings to a special section of the full help.
if !filtering {
if b, ok := m.delegate.(help.KeyMap); ok {
kb = append(kb, b.FullHelp()...)
}
}
listLevelBindings := []key.Binding{
m.KeyMap.Filter,
m.KeyMap.ClearFilter,
m.KeyMap.AcceptWhileFiltering,
m.KeyMap.CancelWhileFiltering,
m.KeyMap.DeleteSelection,
}
if !filtering && m.AdditionalFullHelpKeys != nil {
listLevelBindings = append(listLevelBindings, m.AdditionalFullHelpKeys()...)
}
return append(kb,
listLevelBindings,
[]key.Binding{
m.KeyMap.Quit,
m.KeyMap.CloseFullHelp,
})
}
// View renders the component.
func (m Model) View() string {
var (
sections []string
availHeight = m.height
)
if m.showTitle || (m.showFilter && m.filteringEnabled) {
v := m.titleView()
sections = append(sections, v)
availHeight -= lipgloss.Height(v)
}
if m.showStatusBar {
v := m.statusView()
sections = append(sections, v)
availHeight -= lipgloss.Height(v)
}
var pagination string
if m.showPagination {
pagination = m.paginationView()
availHeight -= lipgloss.Height(pagination)
}
var help string
if m.showHelp {
help = m.helpView()
availHeight -= lipgloss.Height(help)
}
content := lipgloss.NewStyle().Height(availHeight).Render(m.populatedView())
sections = append(sections, content)
if m.showPagination {
sections = append(sections, pagination)
}
if m.showHelp {
sections = append(sections, help)
}
return lipgloss.JoinVertical(lipgloss.Left, sections...)
}
func (m Model) titleView() string {
var (
view string
titleBarStyle = m.Styles.TitleBar.Copy()
// We need to account for the size of the spinner, even if we don't
// render it, to reserve some space for it should we turn it on later.
spinnerView = m.spinnerView()
spinnerWidth = lipgloss.Width(spinnerView)
spinnerLeftGap = " "
spinnerOnLeft = titleBarStyle.GetPaddingLeft() >= spinnerWidth+lipgloss.Width(spinnerLeftGap) && m.showSpinner
)
// If the filter's showing, draw that. Otherwise draw the title.
if m.showFilter && m.filterState == Filtering {
view += m.FilterInput.View()
} else if m.showTitle {
if m.showSpinner && spinnerOnLeft {
view += spinnerView + spinnerLeftGap
titleBarGap := titleBarStyle.GetPaddingLeft()
titleBarStyle = titleBarStyle.PaddingLeft(titleBarGap - spinnerWidth - lipgloss.Width(spinnerLeftGap))
}
view += m.Styles.Title.Render(m.Title)
// Status message
if m.filterState != Filtering {
view += " " + m.statusMessage
view = truncate.StringWithTail(view, uint(m.width-spinnerWidth), ellipsis)
}
}
// Spinner
if m.showSpinner && !spinnerOnLeft {
// Place spinner on the right
availSpace := m.width - lipgloss.Width(m.Styles.TitleBar.Render(view))
if availSpace > spinnerWidth {
view += strings.Repeat(" ", availSpace-spinnerWidth)
view += spinnerView
}
}
return titleBarStyle.Render(view)
}
func (m Model) statusView() string {
var status string
totalItems := len(m.items)
visibleItems := len(m.VisibleItems())
plural := ""
if visibleItems != 1 {
plural = "s"
}
if m.filterState == Filtering {
// Filter results
if visibleItems == 0 {
status = m.Styles.StatusEmpty.Render("Nothing matched")
} else {
status = fmt.Sprintf("%d item%s", visibleItems, plural)
}
} else if len(m.items) == 0 {
// Not filtering: no items.
status = m.Styles.StatusEmpty.Render("No items")
} else {
// Normal
filtered := m.FilterState() == FilterApplied
if filtered {
f := strings.TrimSpace(m.FilterInput.Value())
f = truncate.StringWithTail(f, 10, "…")
status += fmt.Sprintf("“%s” ", f)
}
status += fmt.Sprintf("%d item%s", visibleItems, plural)
}
numFiltered := totalItems - visibleItems
if numFiltered > 0 {
status += m.Styles.DividerDot.String()
status += m.Styles.StatusBarFilterCount.Render(fmt.Sprintf("%d filtered", numFiltered))
}
return m.Styles.StatusBar.Render(status)
}
func (m Model) paginationView() string {
if m.Paginator.TotalPages < 2 { //nolint:gomnd
return ""
}
s := m.Paginator.View()
// If the dot pagination is wider than the width of the window
// use the arabic paginator.
if ansi.PrintableRuneWidth(s) > m.width {
m.Paginator.Type = paginator.Arabic
s = m.Styles.ArabicPagination.Render(m.Paginator.View())
}
style := m.Styles.PaginationStyle
if m.delegate.Spacing() == 0 && style.GetMarginTop() == 0 {
style = style.Copy().MarginTop(1)
}
return style.Render(s)
}
func (m Model) populatedView() string {
items := m.VisibleItems()
b := strings.Builder{}
// Empty states
if len(items) == 0 {
if m.filterState == Filtering {
return ""
}
m.Styles.NoItems.Render("No items found.")
}
if len(items) > 0 {
start, end := m.Paginator.GetSliceBounds(len(items))
docs := items[start:end]
for i, item := range docs {
m.delegate.Render(&b, m, i+start, item)
if i != len(docs)-1 {
fmt.Fprint(&b, strings.Repeat("\n", m.delegate.Spacing()+1))
}
}
}
// If there aren't enough items to fill up this page (always the last page)
// then we need to add some newlines to fill up the space where items would
// have been.
//itemsOnPage := m.Paginator.ItemsOnPage(len(items))
//if itemsOnPage < m.Paginator.PerPage {
// n := (m.Paginator.PerPage - itemsOnPage) * (m.delegate.Height() + m.delegate.Spacing())
// if len(items) == 0 {
// n -= m.delegate.Height() - 1
// }
// fmt.Fprint(&b, strings.Repeat("\n", n))
//}
ret := b.String()
return ret
}
func (m Model) helpView() string {
return m.Styles.HelpStyle.Render(m.Help.View(m))
}
func (m Model) spinnerView() string {
return m.spinner.View()
}
func filterItems(m Model) tea.Cmd {
return func() tea.Msg {
if m.FilterInput.Value() == "" || m.filterState == Unfiltered {
return FilterMatchesMessage(m.itemsAsFilterItems()) // return nothing
}
var targets []string
items := m.items
for _, t := range items {
targets = append(targets, t.FilterValue())
}
var ranks = fuzzy.Find(m.FilterInput.Value(), targets)
sort.Stable(ranks)
var filterMatches []filteredItem
for _, r := range ranks {
filterMatches = append(filterMatches, filteredItem{
item: items[r.Index],
matches: r.MatchedIndexes,
})
}
return FilterMatchesMessage(filterMatches)
}
}
func insertItemIntoSlice(items []Item, item Item, index int) []Item {
if items == nil {
return []Item{item}
}
if index >= len(items) {
return append(items, item)
}
index = max(0, index)
items = append(items, nil)
copy(items[index+1:], items[index:])
items[index] = item
return items
}
// Remove an item from a slice of items at the given index. This runs in O(n).
func removeItemFromSlice(i []Item, index int) []Item {
if index >= len(i) {
return i // noop
}
copy(i[index:], i[index+1:])
i[len(i)-1] = nil
return i[:len(i)-1]
}
func removeFilterMatchFromSlice(i []filteredItem, index int) []filteredItem {
if index >= len(i) {
return i // noop
}
copy(i[index:], i[index+1:])
i[len(i)-1] = filteredItem{}
return i[:len(i)-1]
}
func countEnabledBindings(groups [][]key.Binding) (agg int) {
for _, group := range groups {
for _, kb := range group {
if kb.Enabled() {
agg++
}
}
}
return agg
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
================================================
FILE: list/style.go
================================================
package list
import (
"github.com/charmbracelet/lipgloss"
"github.com/mathaou/termdbms/tuiutil"
)
const (
bullet = "•"
ellipsis = "…"
)
// Styles contains style definitions for this list component. By default, these
// values are generated by DefaultStyles.
type Styles struct {
TitleBar lipgloss.Style
Title lipgloss.Style
Spinner lipgloss.Style
FilterPrompt lipgloss.Style
FilterCursor lipgloss.Style
// Default styling for matched characters in a filter. This can be
// overridden by delegates.
DefaultFilterCharacterMatch lipgloss.Style
StatusBar lipgloss.Style
StatusEmpty lipgloss.Style
StatusBarActiveFilter lipgloss.Style
StatusBarFilterCount lipgloss.Style
NoItems lipgloss.Style
PaginationStyle lipgloss.Style
HelpStyle lipgloss.Style
// Styled characters.
ActivePaginationDot lipgloss.Style
InactivePaginationDot lipgloss.Style
ArabicPagination lipgloss.Style
DividerDot lipgloss.Style
}
// DefaultStyles returns a set of default style definitions for this list
// component.
func DefaultStyles() (s Styles) {
verySubduedColor := lipgloss.AdaptiveColor{Light: "#DDDADA", Dark: "#3C3C3C"}
subduedColor := lipgloss.AdaptiveColor{Light: "#9B9B9B", Dark: "#5C5C5C"}
s.TitleBar = lipgloss.NewStyle().Padding(0, 0, 1, 2)
s.Title = lipgloss.NewStyle().
Background(lipgloss.Color(tuiutil.HeaderBackground())).
Foreground(lipgloss.Color(tuiutil.HeaderForeground())).
Padding(0, 1)
s.Spinner = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#8E8E8E", Dark: "#747373"})
s.FilterPrompt = lipgloss.NewStyle().
Foreground(lipgloss.Color(tuiutil.FooterForeground()))
s.FilterCursor = lipgloss.NewStyle().
Foreground(lipgloss.Color(tuiutil.BorderColor()))
s.DefaultFilterCharacterMatch = lipgloss.NewStyle().Underline(true)
s.StatusBar = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#A49FA5", Dark: "#777777"}).
Padding(0, 0, 1, 2)
s.StatusEmpty = lipgloss.NewStyle().Foreground(subduedColor)
s.StatusBarActiveFilter = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#1a1a1a", Dark: "#dddddd"})
s.StatusBarFilterCount = lipgloss.NewStyle().Foreground(verySubduedColor)
s.NoItems = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#909090", Dark: "#626262"})
s.ArabicPagination = lipgloss.NewStyle().Foreground(subduedColor)
s.PaginationStyle = lipgloss.NewStyle().PaddingLeft(2) //nolint:gomnd
s.HelpStyle = lipgloss.NewStyle().Padding(1, 0, 0, 2)
s.ActivePaginationDot = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#847A85", Dark: "#979797"}).
SetString(bullet)
s.InactivePaginationDot = lipgloss.NewStyle().
Foreground(verySubduedColor).
SetString(bullet)
s.DividerDot = lipgloss.NewStyle().
Foreground(verySubduedColor).
SetString(" " + bullet + " ")
return s
}
================================================
FILE: main.go
================================================
package main
import (
"database/sql"
"flag"
"fmt"
"io/fs"
"io/ioutil"
"os"
"path/filepath"
"strings"
. "github.com/mathaou/termdbms/tuiutil"
. "github.com/mathaou/termdbms/viewer"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/mathaou/termdbms/database"
"github.com/muesli/termenv"
_ "modernc.org/sqlite"
)
type DatabaseType string
const (
debugPath = "" // set to whatever hardcoded path for testing
)
const (
DatabaseSQLite DatabaseType = "sqlite"
DatabaseMySQL DatabaseType = "mysql"
)
var (
debug bool
path string
databaseType string
theme string
help bool
ascii bool
)
func main() {
debug = debugPath != ""
flag.Usage = func() {
help := GetHelpText()
lines := strings.Split(help, "\n")
for _, v := range lines {
println(v)
}
}
argLength := len(os.Args[1:])
if (argLength > 4 || argLength == 0) && !debug {
fmt.Printf("ERROR: Invalid number of arguments supplied: %d\n", argLength)
flag.Usage()
os.Exit(1)
}
// flags declaration using flag package
flag.StringVar(&databaseType, "d", string(DatabaseSQLite), "Specifies the SQL driver to use. Defaults to SQLite.")
flag.StringVar(&path, "p", "", "Path to the database file.")
flag.StringVar(&theme, "t", "default", "sets the color theme of the app.")
flag.BoolVar(&help, "h", false, "Prints the help message.")
flag.BoolVar(&ascii, "a", false, "Denotes that the app should render with minimal styling to remove ANSI sequences.")
flag.Parse()
handleFlags()
var c *sql.Rows
defer func() {
if c != nil {
c.Close()
}
}()
if debug {
path = debugPath
}
for i, v := range ValidThemes {
if theme == v {
SelectedTheme = i
break
}
}
if theme == "" {
theme = "default"
}
// gets a sqlite instance for the database file
if exists, _ := FileExists(path); exists {
fmt.Printf("ERROR: Database file could not be found at %s\n", path)
os.Exit(1)
}
if valid, _ := Exists(HiddenTmpDirectoryName); valid {
filepath.Walk(HiddenTmpDirectoryName, func(path string, info fs.FileInfo, err error) error {
if strings.HasPrefix(path, fmt.Sprintf("%s/.", HiddenTmpDirectoryName)) && !info.IsDir() {
os.Remove(path) // remove all temp databaess
}
return nil
})
} else {
os.Mkdir(HiddenTmpDirectoryName, 0o777)
}
database.IsCSV = strings.HasSuffix(path, ".csv")
dst := path
if database.IsCSV { // convert the csv to sql, then run the sql through a database
sqlFile := strings.TrimSuffix(path, ".csv")
sqlFile = filepath.Base(sqlFile)
path = Convert(path, sqlFile, true)
csvDBFile := HiddenTmpDirectoryName + "/" + sqlFile + ".db"
os.Create(csvDBFile)
dst, _ = filepath.Abs(csvDBFile)
d, _ := sql.Open(database.DriverString, dst)
f, _ := os.Open(path)
b, _ := ioutil.ReadAll(f)
query := string(b)
_, err := d.Exec(query)
if err != nil {
fmt.Printf("%v", err)
os.Exit(1)
}
d.Close()
os.Remove(path) // this deletes the converted .sql file
}
dst, _, _ = CopyFile(dst)
db := database.GetDatabaseForFile(dst)
defer func() {
if db == nil {
db.Close()
}
}()
// initializes the model used by bubbletea
m := GetNewModel(dst, db)
InitialModel = &m
InitialModel.InitialFileName = path
err := InitialModel.SetModel(c, db)
if err != nil {
fmt.Printf("%v", err)
os.Exit(1)
}
// creates the program
Program = tea.NewProgram(InitialModel,
tea.WithAltScreen(),
tea.WithMouseAllMotion())
if err := Program.Start(); err != nil {
fmt.Printf("ERROR: Error initializing the sqlite viewer: %v", err)
os.Exit(1)
}
}
func handleFlags() {
if path == "" && !debug {
fmt.Printf("ERROR: no path for database.\n")
flag.Usage()
os.Exit(1)
}
if help {
flag.Usage()
os.Exit(0)
}
if ascii {
Ascii = true
lipgloss.SetColorProfile(termenv.Ascii)
}
if path != "" && !IsUrl(path) {
fmt.Printf("ERROR: Invalid path %s\n", path)
flag.Usage()
os.Exit(1)
}
if databaseType != string(DatabaseMySQL) &&
databaseType != string(DatabaseSQLite) {
fmt.Printf("Invalid database driver specified: %s", databaseType)
os.Exit(1)
}
database.DriverString = databaseType
}
================================================
FILE: tuiutil/csv2sql.go
================================================
package tuiutil
import (
"bufio"
"bytes"
"encoding/csv"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
/*
csv2sql - conversion program to convert a csv file to sql format
to allow easy checking / validation, and for import into a SQLite3
database using the SQLite '.read' command
author: simon rowe <simon@wiremoons.com>
license: open-source released under "New BSD License"
created: 16 Apr 2014 - initial outline code written
updated: 17 Apr 2014 - add flags and output file handling
updated: 27 Apr 2014 - wrap in double quotes instead of single
updated: 28 Apr 2014 - add flush io file buffer to fix SQL missing EOF
updated: 19 Jul 2014 - add more help text, tidy up comments and code
updated: 06 Aug 2014 - enabled the -k flag to alter the table header characters
updated: 28 Sep 2014 - changed default output when run with no params, add -h
to display the help info and also still call flags.Usage()
updated: 09 Dec 2014 - minor tidy up and first 'release' provided on GitHub
updated: 27 Aug 2016 - table name and csv file help output minior changes. Minor cosmetic stuff. Version 1.1
*/
func SQLFileName(csvFileName string) string {
// include the name of the csv file from command line (ie csvFileName)
// remove any path etc
var justFileName = filepath.Base(csvFileName)
// get the files extension too
var extension = filepath.Ext(csvFileName)
// remove the file extension from the filename
justFileName = justFileName[0 : len(justFileName)-len(extension)]
sqlOutFile := "./.termdbms/SQL-" + justFileName + ".sql"
return sqlOutFile
}
func Convert(csvFileName, tableName string, keepOrigCols bool) string {
// check we have a table name and csv file to work with - otherwise abort
if csvFileName == "" || tableName == "" {
return ""
}
// open the CSV file - name provided via command line input - handle 'file'
file, err := os.Open(csvFileName)
// error - if we have one exit as CSV file not right
if err != nil {
fmt.Printf("ERROR: %s\n", err)
os.Exit(-3)
}
// now file is open - defer the close of CSV file handle until we return
defer file.Close()
// connect a CSV reader to the file handle - which is the actual opened
// CSV file
// TODO : is there an error from this to check?
reader := csv.NewReader(file)
sqlOutFile := SQLFileName(csvFileName)
// open the new file using the name we obtained above - handle 'filesql'
filesql, err := os.Create(sqlOutFile)
// error - if we have one when trying open & create the new file
if err != nil {
return ""
}
// now new file is open - defer the close of the file handle until we return
defer filesql.Close()
// attach the opened new sql file handle to a buffered file writer
// the buffered file writer has the handle 'sqlFileBuffer'
sqlFileBuffer := bufio.NewWriter(filesql)
//-------------------------------------------------------------------------
// prepare to read the each line of the CSV file - and write out to the SQl
//-------------------------------------------------------------------------
// track the number of lines in the csv file
lineCount := 0
// create a buffer to hold each line of the SQL file as we build it
// handle to this buffer is called 'strbuffer'
var strbuffer bytes.Buffer
// START - processing of each line in the CSV input file
//-------------------------------------------------------------------------
// loop through the csv file until EOF - or until we hit an error in parsing it.
// Data is read in for each line of the csv file and held in the variable
// 'record'. Build a string for each line - wrapped with the SQL and
// then output to the SQL file writer in its completed new form
//-------------------------------------------------------------------------
for {
record, err := reader.Read()
// if we hit end of file (EOF) or another unexpected error
if err == io.EOF {
break
} else if err != nil {
return ""
}
// if we are processing the first line - use the record field contents
// as the SQL table column names - add to the temp string 'strbuffer'
// use the tablename provided by the user
if lineCount == 0 {
strbuffer.WriteString("CREATE TABLE " + tableName + " (")
}
// if any line except the first one :
// print the start of the SQL insert statement for the record
// and - add to the temp string 'strbuffer'
// use the tablename provided by the user
if lineCount > 0 {
strbuffer.WriteString("INSERT INTO " + tableName + " VALUES (")
}
// loop through each of the csv lines individual fields held in 'record'
// len(record) tells us how many fields are on this line - so we loop right number of times
for i := 0; i < len(record); i++ {
// if we are processing the first line used for the table column name - update the
// record field contents to remove the characters: space | - + @ # / \ : ( ) '
// from the SQL table column names. Can be overridden on command line with '-k true'
if (lineCount == 0) && (keepOrigCols == false) {
// call the function cleanHeader to do clean up on this field
record[i] = cleanHeader(record[i])
}
// if a csv record field is empty or has the text "NULL" - replace it with actual NULL field in SQLite
// otherwise just wrap the existing content with ''
// TODO : make sure we don't try to create a 'NULL' table column name?
if len(record[i]) == 0 || record[i] == "NULL" {
strbuffer.WriteString("NULL")
} else {
strbuffer.WriteString("\"" + record[i] + "\"")
}
// if we have not reached the last record yet - add a coma also to the output
if i < len(record)-1 {
strbuffer.WriteString(",")
}
}
// end of the line - so output SQL format required ');' and newline
strbuffer.WriteString(");\n")
// line of SQL is complete - so push out to the new SQL file
bWritten, err := sqlFileBuffer.WriteString(strbuffer.String())
// check it wrote data ok - otherwise report the error giving the line number affected
if (err != nil) || (bWritten != len(strbuffer.Bytes())) {
return ""
}
// reset the string buffer - so it is empty ready for the next line to build
strbuffer.Reset()
// for debug - show the line number we are processing from the CSV file
// increment the line count - and loop back around for next line of the CSV file
lineCount += 1
}
// write out final line to the SQL file
bWritten, err := sqlFileBuffer.WriteString(strbuffer.String())
// check it wrote data ok - otherwise report the error giving the line number affected
if (err != nil) || (bWritten != len(strbuffer.Bytes())) {
return ""
}
strbuffer.WriteString("\nCOMMIT;")
// finished the SQl file data writing - flush any IO buffers
// NB below flush required as the data was being lost otherwise - maybe a bug in go version 1.2 only?
sqlFileBuffer.Flush()
// reset the string buffer - so it is empty as it is no longer needed
strbuffer.Reset()
return sqlOutFile
}
func cleanHeader(headField string) string {
// ok - remove any spaces and replace with _
headField = strings.Replace(headField, " ", "_", -1)
// ok - remove any | and replace with _
headField = strings.Replace(headField, "|", "_", -1)
// ok - remove any - and replace with _
headField = strings.Replace(headField, "-", "_", -1)
// ok - remove any + and replace with _
headField = strings.Replace(headField, "+", "_", -1)
// ok - remove any @ and replace with _
headField = strings.Replace(headField, "@", "_", -1)
// ok - remove any # and replace with _
headField = strings.Replace(headField, "#", "_", -1)
// ok - remove any / and replace with _
headField = strings.Replace(headField, "/", "_", -1)
// ok - remove any \ and replace with _
headField = strings.Replace(headField, "\\", "_", -1)
// ok - remove any : and replace with _
headField = strings.Replace(headField, ":", "_", -1)
// ok - remove any ( and replace with _
headField = strings.Replace(headField, "(", "_", -1)
// ok - remove any ) and replace with _
headField = strings.Replace(headField, ")", "_", -1)
// ok - remove any ' and replace with _
headField = strings.Replace(headField, "'", "_", -1)
return headField
}
================================================
FILE: tuiutil/textinput.go
================================================
package tuiutil
import (
"context"
"strings"
"sync"
"time"
"unicode"
"github.com/atotto/clipboard"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
rw "github.com/mattn/go-runewidth"
)
const DefaultBlinkSpeed = time.Millisecond * 530
// Internal ID management for text inputs. Necessary for blink integrity when
// multiple text inputs are involved.
var (
Ascii bool
lastID int
idMtx sync.Mutex
)
// Return the next ID we should use on the TextInputModel.
func nextID() int {
idMtx.Lock()
defer idMtx.Unlock()
lastID++
return lastID
}
// initialBlinkMsg initializes cursor blinking.
type initialBlinkMsg struct{}
// blinkMsg signals that the cursor should blink. It contains metadata that
// allows us to tell if the blink message is the one we're expecting.
type blinkMsg struct {
id int
tag int
}
// blinkCanceled is sent when a blink operation is canceled.
type blinkCanceled struct{}
// Internal messages for clipboard operations.
type pasteMsg string
type pasteErrMsg struct{ error }
// EchoMode sets the input behavior of the text input field.
type EchoMode int
const (
// EchoNormal displays text as is. This is the default behavior.
EchoNormal EchoMode = iota
// EchoPassword displays the EchoCharacter mask instead of actual
// characters. This is commonly used for password fields.
EchoPassword
// EchoNone displays nothing as characters are entered. This is commonly
// seen for password fields on the command line.
EchoNone
// EchoOnEdit
)
// blinkCtx manages cursor blinking.
type blinkCtx struct {
ctx context.Context
cancel context.CancelFunc
}
// CursorMode describes the behavior of the cursor.
type CursorMode int
// Available cursor modes.
const (
CursorBlink CursorMode = iota
CursorStatic
CursorHide
)
// String returns a the cursor mode in a human-readable format. This method is
// provisional and for informational purposes only.
func (c CursorMode) String() string {
return [...]string{
"blink",
"static",
"hidden",
}[c]
}
// TextInputModel is the Bubble Tea model for this text input element.
type TextInputModel struct {
Err error
// General settings.
Prompt string
Placeholder string
BlinkSpeed time.Duration
EchoMode EchoMode
EchoCharacter rune
// Styles. These will be applied as inline styles.
//
// For an introduction to styling with Lip Gloss see:
// https://github.com/charmbracelet/lipgloss
PromptStyle lipgloss.Style
TextStyle lipgloss.Style
BackgroundStyle lipgloss.Style
PlaceholderStyle lipgloss.Style
CursorStyle lipgloss.Style
// CharLimit is the maximum amount of characters this input element will
// accept. If 0 or less, there's no limit.
CharLimit int
// Width is the maximum number of characters that can be displayed at once.
// It essentially treats the text field like a horizontally scrolling
// Viewport. If 0 or less this setting is ignored.
Width int
// The ID of this TextInputModel as it relates to other textinput Models.
id int
// The ID of the blink message we're expecting to receive.
blinkTag int
// Underlying text value.
value []rune
// Focus indicates whether user input Focus should be on this input
// component. When false, ignore keyboard input and hide the cursor.
Focus bool
// Cursor blink state.
blink bool
// Cursor position.
pos int
// Used to emulate a Viewport when width is set and the content is
// overflowing.
Offset int
OffsetRight int
// Used to manage cursor blink
blinkCtx *blinkCtx
// cursorMode determines the behavior of the cursor
cursorMode CursorMode
}
// NewModel creates a new model with default settings.
func NewModel() TextInputModel {
m := TextInputModel{
Prompt: "> ",
BlinkSpeed: DefaultBlinkSpeed,
EchoCharacter: '*',
CharLimit: 0,
PlaceholderStyle: lipgloss.NewStyle(),
id: nextID(),
value: nil,
Focus: false,
blink: true,
pos: 0,
cursorMode: CursorBlink,
blinkCtx: &blinkCtx{
ctx: context.Background(),
},
}
if !Ascii {
m.PlaceholderStyle = m.PlaceholderStyle.Foreground(lipgloss.Color("240"))
}
return m
}
// SetValue sets the value of the text input.
func (m *TextInputModel) SetValue(s string) {
runes := []rune(s)
if m.CharLimit > 0 && len(runes) > m.CharLimit {
m.value = runes[:m.CharLimit]
} else {
m.value = runes
}
if m.pos == 0 || m.pos > len(m.value) {
m.setCursor(len(m.value))
}
m.handleOverflow()
}
// Value returns the value of the text input.
func (m TextInputModel) Value() string {
return string(m.value)
}
// Cursor returns the cursor position.
func (m TextInputModel) Cursor() int {
return m.pos
}
// SetCursor moves the cursor to the given position. If the position is
// out of bounds the cursor will be moved to the start or end accordingly.
func (m *TextInputModel) SetCursor(pos int) {
m.setCursor(pos)
}
// setCursor moves the cursor to the given position and returns whether or not
// the cursor blink should be reset. If the position is out of bounds the
// cursor will be moved to the start or end accordingly.
func (m *TextInputModel) setCursor(pos int) bool {
m.pos = Clamp(pos, 0, len(m.value))
m.handleOverflow()
// Show the cursor unless it's been explicitly hidden
m.blink = m.cursorMode == CursorHide
// Reset cursor blink if necessary
return m.cursorMode == CursorBlink
}
// CursorStart moves the cursor to the start of the input field.
func (m *TextInputModel) CursorStart() {
m.cursorStart()
}
// cursorStart moves the cursor to the start of the input field and returns
// whether or not the curosr blink should be reset.
func (m *TextInputModel) cursorStart() bool {
return m.setCursor(0)
}
// CursorEnd moves the cursor to the end of the input field
func (m *TextInputModel) CursorEnd() {
m.cursorEnd()
}
// CursorMode returns the model's cursor mode. For available cursor modes, see
// type CursorMode.
func (m TextInputModel) CursorMode() CursorMode {
return m.cursorMode
}
// SetCursorMode CursorMode sets the model's cursor mode. This method returns a command.
//
// For available cursor modes, see type CursorMode.
func (m *TextInputModel) SetCursorMode(mode CursorMode) tea.Cmd {
m.cursorMode = mode
m.blink = m.cursorMode == CursorHide || !m.Focus
if mode == CursorBlink {
return Blink
}
return nil
}
// cursorEnd moves the cursor to the end of the input field and returns whether
// the cursor should blink should reset.
func (m *TextInputModel) cursorEnd() bool {
return m.setCursor(len(m.value))
}
// Focused returns the Focus state on the model.
func (m TextInputModel) Focused() bool {
return m.Focus
}
// FocusCommand sets the Focus state on the model. When the model is in Focus it can
// receive keyboard input and the cursor will be hidden.
func (m *TextInputModel) FocusCommand() tea.Cmd {
m.Focus = true
m.blink = m.cursorMode == CursorHide // show the cursor unless we've explicitly hidden it
if m.cursorMode == CursorBlink && m.Focus {
return m.blinkCmd()
}
return nil
}
// Blur removes the Focus state on the model. When the model is blurred it can
// not receive keyboard input and the cursor will be hidden.
func (m *TextInputModel) Blur() {
m.Focus = false
m.blink = true
}
// Reset sets the input to its default state with no input. Returns whether
// or not the cursor blink should reset.
func (m *TextInputModel) Reset() bool {
m.value = nil
return m.setCursor(0)
}
// handle a clipboard paste event, if supported. Returns whether or not the
// cursor blink should reset.
func (m *TextInputModel) handlePaste(v string) bool {
paste := []rune(v)
var availSpace int
if m.CharLimit > 0 {
availSpace = m.CharLimit - len(m.value)
}
// If the char limit's been reached cancel
if m.CharLimit > 0 && availSpace <= 0 {
return false
}
// If there's not enough space to paste the whole thing cut the pasted
// runes down so they'll fit
if m.CharLimit > 0 && availSpace < len(paste) {
paste = paste[:len(paste)-availSpace]
}
// Stuff before and after the cursor
head := m.value[:m.pos]
tailSrc := m.value[m.pos:]
tail := make([]rune, len(tailSrc))
copy(tail, tailSrc)
// Insert pasted runes
for _, r := range paste {
head = append(head, r)
m.pos++
if m.CharLimit > 0 {
availSpace--
if availSpace <= 0 {
break
}
}
}
// Put it all back together
m.value = append(head, tail...)
// Reset blink state if necessary and run overflow checks
return m.setCursor(m.pos)
}
// If a max width is defined, perform some logic to treat the visible area
// as a horizontally scrolling Viewport.
func (m *TextInputModel) handleOverflow() {
if m.Width <= 0 || rw.StringWidth(string(m.value)) <= m.Width {
m.Offset = 0
m.OffsetRight = len(m.value)
return
}
// Correct right Offset if we've deleted characters
m.OffsetRight = min(m.OffsetRight, len(m.value))
if m.pos < m.Offset {
m.Offset = m.pos
w := 0
i := 0
runes := m.value[m.Offset:]
for i < len(runes) && w <= m.Width {
w += rw.RuneWidth(runes[i])
if w <= m.Width+1 {
i++
}
}
m.OffsetRight = m.Offset + i
} else if m.pos >= m.OffsetRight {
m.OffsetRight = m.pos
w := 0
runes := m.value[:m.OffsetRight]
i := len(runes) - 1
for i > 0 && w < m.Width {
w += rw.RuneWidth(runes[i])
if w <= m.Width {
i--
}
}
m.Offset = m.OffsetRight - (len(runes) - 1 - i)
}
}
// deleteBeforeCursor deletes all text before the cursor. Returns whether or
// not the cursor blink should be reset.
func (m *TextInputModel) deleteBeforeCursor() bool {
m.value = m.value[m.pos:]
m.Offset = 0
return m.setCursor(0)
}
// deleteAfterCursor deletes all text after the cursor. Returns whether or not
// the cursor blink should be reset. If input is masked delete everything after
// the cursor so as not to reveal word breaks in the masked input.
func (m *TextInputModel) deleteAfterCursor() bool {
m.value = m.value[:m.pos]
return m.setCursor(len(m.value))
}
// deleteWordLeft deletes the word left to the cursor. Returns whether or not
// the cursor blink should be reset.
func (m *TextInputModel) deleteWordLeft() bool {
if m.pos == 0 || len(m.value) == 0 {
return false
}
if m.EchoMode != EchoNormal {
return m.deleteBeforeCursor()
}
i := m.pos
blink := m.setCursor(m.pos - 1)
for unicode.IsSpace(m.value[m.pos]) {
// ignore series of whitespace before cursor
blink = m.setCursor(m.pos - 1)
}
for m.pos > 0 {
if !unicode.IsSpace(m.value[m.pos]) {
blink = m.setCursor(m.pos - 1)
} else {
if m.pos > 0 {
// keep the previous space
blink = m.setCursor(m.pos + 1)
}
break
}
}
if i > len(m.value) {
m.value = m.value[:m.pos]
} else {
m.value = append(m.value[:m.pos], m.value[i:]...)
}
return blink
}
// deleteWordRight deletes the word right to the cursor. Returns whether or not
// the cursor blink should be reset. If input is masked delete everything after
// the cursor so as not to reveal word breaks in the masked input.
func (m *TextInputModel) deleteWordRight() bool {
if m.pos >= len(m.value) || len(m.value) == 0 {
return false
}
if m.EchoMode != EchoNormal {
return m.deleteAfterCursor()
}
i := m.pos
m.setCursor(m.pos + 1)
for unicode.IsSpace(m.value[m.pos]) {
// ignore series of whitespace after cursor
m.setCursor(m.pos + 1)
}
for m.pos < len(m.value) {
if !unicode.IsSpace(m.value[m.pos]) {
m.setCursor(m.pos + 1)
} else {
break
}
}
if m.pos > len(m.value) {
m.value = m.value[:i]
} else {
m.value = append(m.value[:i], m.value[m.pos:]...)
}
return m.setCursor(i)
}
// wordLeft moves the cursor one word to the left. Returns whether or not the
// cursor blink should be reset. If input is masked, move input to the start
// so as not to reveal word breaks in the masked input.
func (m *TextInputModel) wordLeft() bool {
if m.pos == 0 || len(m.value) == 0 {
return false
}
if m.EchoMode != EchoNormal {
return m.cursorStart()
}
blink := false
i := m.pos - 1
for i >= 0 {
if unicode.IsSpace(m.value[i]) {
blink = m.setCursor(m.pos - 1)
i--
} else {
break
}
}
for i >= 0 {
if !unicode.IsSpace(m.value[i]) {
blink = m.setCursor(m.pos - 1)
i--
} else {
break
}
}
return blink
}
// wordRight moves the cursor one word to the right. Returns whether or not the
// cursor blink should be reset. If the input is masked, move input to the end
// so as not to reveal word breaks in the masked input.
func (m *TextInputModel) wordRight() bool {
if m.pos >= len(m.value) || len(m.value) == 0 {
return false
}
if m.EchoMode != EchoNormal {
return m.cursorEnd()
}
blink := false
i := m.pos
for i < len(m.value) {
if unicode.IsSpace(m.value[i]) {
blink = m.setCursor(m.pos + 1)
i++
} else {
break
}
}
for i < len(m.value) {
if !unicode.IsSpace(m.value[i]) {
blink = m.setCursor(m.pos + 1)
i++
} else {
break
}
}
return blink
}
func (m TextInputModel) echoTransform(v string) string {
switch m.EchoMode {
case EchoPassword:
return strings.Repeat(string(m.EchoCharacter), rw.StringWidth(v))
case EchoNone:
return ""
default:
return v
}
}
// Update is the Bubble Tea update loop.
func (m TextInputModel) Update(msg tea.Msg) (TextInputModel, tea.Cmd) {
if !m.Focus {
m.blink = true
return m, nil
}
var resetBlink bool
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.Type {
case tea.KeyBackspace: // delete character before cursor
if msg.Alt {
resetBlink = m.deleteWordLeft()
} else {
if len(m.value) > 0 {
m.value = append(m.value[:max(0, m.pos-1)], m.value[m.pos:]...)
if m.pos > 0 {
resetBlink = m.setCursor(m.pos - 1)
}
}
}
case tea.KeyLeft, tea.KeyCtrlB:
if msg.Alt { // alt+left arrow, back one word
resetBlink = m.wordLeft()
break
}
if m.pos > 0 { // left arrow, ^F, back one character
resetBlink = m.setCursor(m.pos - 1)
}
case tea.KeyRight, tea.KeyCtrlF:
if msg.Alt { // alt+right arrow, forward one word
resetBlink = m.wordRight()
break
}
if m.pos < len(m.value) { // right arrow, ^F, forward one character
resetBlink = m.setCursor(m.pos + 1)
}
case tea.KeyCtrlW: // ^W, delete word left of cursor
resetBlink = m.deleteWordLeft()
case tea.KeyHome, tea.KeyCtrlA: // ^A, go to beginning
resetBlink = m.cursorStart()
case tea.KeyDelete, tea.KeyCtrlD: // ^D, delete char under cursor
if len(m.value) > 0 && m.pos < len(m.value) {
m.value = append(m.value[:m.pos], m.value[m.pos+1:]...)
}
case tea.KeyCtrlE, tea.KeyEnd: // ^E, go to end
resetBlink = m.cursorEnd()
case tea.KeyCtrlK: // ^K, kill text after cursor
resetBlink = m.deleteAfterCursor()
case tea.KeyCtrlU: // ^U, kill text before cursor
resetBlink = m.deleteBeforeCursor()
case tea.KeyCtrlV: // ^V paste
return m, Paste
case tea.KeyRunes: // input regular characters
if msg.Alt && len(msg.Runes) == 1 {
if msg.Runes[0] == 'd' { // alt+d, delete word right of cursor
resetBlink = m.deleteWordRight()
break
}
if msg.Runes[0] == 'b' { // alt+b, back one word
resetBlink = m.wordLeft()
break
}
if msg.Runes[0] == 'f' { // alt+f, forward one word
resetBlink = m.wordRight()
break
}
}
// Input a regular character
if m.CharLimit <= 0 || len(m.value) < m.CharLimit {
m.value = append(m.value[:m.pos], append(msg.Runes, m.value[m.pos:]...)...)
resetBlink = m.setCursor(m.pos + len(msg.Runes))
}
}
case initialBlinkMsg:
// We accept all initialBlinkMsgs genrated by the Blink command.
if m.cursorMode != CursorBlink || !m.Focus {
return m, nil
}
cmd := m.blinkCmd()
return m, cmd
case blinkMsg:
// We're choosy about whether to accept blinkMsgs so that our cursor
// only exactly when it should.
// Is this model blinkable?
if m.cursorMode != CursorBlink || !m.Focus {
return m, nil
}
// Were we expecting this blink message?
if msg.id != m.id || msg.tag != m.blinkTag {
return m, nil
}
var cmd tea.Cmd
if m.cursorMode == CursorBlink {
m.blink = !m.blink
cmd = m.blinkCmd()
}
return m, cmd
case blinkCanceled: // no-op
return m, nil
case pasteMsg:
resetBlink = m.handlePaste(string(msg))
case pasteErrMsg:
m.Err = msg
}
var cmd tea.Cmd
if resetBlink {
cmd = m.blinkCmd()
}
m.handleOverflow()
return m, cmd
}
// View renders the textinput in its current state.
func (m TextInputModel) View() string {
// Placeholder text
if len(m.value) == 0 && m.Placeholder != "" {
return m.placeholderView()
}
styleText := m.TextStyle.Inline(true).Render
value := m.value[m.Offset:m.OffsetRight]
pos := max(0, m.pos-m.Offset)
v := styleText(m.echoTransform(string(value[:pos])))
if pos < len(value) {
if Ascii {
v += "¦"
}
v += m.cursorView(m.echoTransform(string(value[pos]))) // cursor and text under it
v += styleText(m.echoTransform(string(value[pos+1:]))) // text after cursor
} else {
v += m.cursorView(" ")
}
// If a max width and background color were set fill the empty spaces with
// the background color.
valWidth := rw.StringWidth(string(value))
if m.Width > 0 && valWidth <= m.Width {
padding := max(0, m.Width-valWidth)
if valWidth+padding <= m.Width && pos < len(value) {
padding++
}
v += styleText(strings.Repeat(" ", padding))
}
return m.PromptStyle.Render(m.Prompt) + v
}
// placeholderView returns the prompt and placeholder view, if any.
func (m TextInputModel) placeholderView() string {
var (
v string
p = m.Placeholder
style = m.PlaceholderStyle.Inline(true).Render
)
// Cursor
if m.blink {
v += m.cursorView(style(p[:1]))
} else {
v += m.cursorView(p[:1])
}
// The rest of the placeholder text
v += style(p[1:])
return m.PromptStyle.Render(m.Prompt) + v
}
// cursorView styles the cursor.
func (m TextInputModel) cursorView(v string) string {
if m.blink {
return m.TextStyle.Render(v)
}
s := m.CursorStyle.Inline(true)
if !Ascii {
s = s.Reverse(true)
}
return s.Render(v)
}
// blinkCmd is an internal command used to manage cursor blinking.
func (m *TextInputModel) blinkCmd() tea.Cmd {
if m.cursorMode != CursorBlink {
return nil
}
if m.blinkCtx != nil && m.blinkCtx.cancel != nil {
m.blinkCtx.cancel()
}
ctx, cancel := context.WithTimeout(m.blinkCtx.ctx, m.BlinkSpeed)
m.blinkCtx.cancel = cancel
m.blinkTag++
return func() tea.Msg {
defer cancel()
<-ctx.Done()
if ctx.Err() == context.DeadlineExceeded {
return blinkMsg{id: m.id, tag: m.blinkTag}
}
return blinkCanceled{}
}
}
// Blink is a command used to initialize cursor blinking.
func Blink() tea.Msg {
return initialBlinkMsg{}
}
// Paste is a command for pasting from the clipboard into the text input.
func Paste() tea.Msg {
str, err := clipboard.ReadAll()
if err != nil {
return pasteErrMsg{err}
}
return pasteMsg(str)
}
func Clamp(v, low, high int) int {
return min(high, max(low, v))
}
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
}
================================================
FILE: tuiutil/theme.go
================================================
package tuiutil
const (
HighlightKey = "Highlight"
HeaderBackgroundKey = "HeaderBackground"
HeaderBorderBackgroundKey = "HeaderBorderBackground"
HeaderForegroundKey = "HeaderForeground"
FooterForegroundColorKey = "FooterForeground"
HeaderBottomColorKey = "HeaderBottom"
HeaderTopForegroundColorKey = "HeaderTopForeground"
BorderColorKey = "BorderColor"
TextColorKey = "TextColor"
)
// styling functions
var (
Highlight = func() string {
return ThemesMap[SelectedTheme][HighlightKey]
} // change to whatever
HeaderBackground = func() string {
return ThemesMap[SelectedTheme][HeaderBackgroundKey]
}
HeaderBorderBackground = func() string {
return ThemesMap[SelectedTheme][HeaderBorderBackgroundKey]
}
HeaderForeground = func() string {
return ThemesMap[SelectedTheme][HeaderForegroundKey]
}
FooterForeground = func() string {
return ThemesMap[SelectedTheme][FooterForegroundColorKey]
}
HeaderBottom = func() string {
return ThemesMap[SelectedTheme][HeaderBottomColorKey]
}
HeaderTopForeground = func() string {
return ThemesMap[SelectedTheme][HeaderTopForegroundColorKey]
}
BorderColor = func() string {
return ThemesMap[SelectedTheme][BorderColorKey]
}
TextColor = func() string {
return ThemesMap[SelectedTheme][TextColorKey]
}
)
var (
SelectedTheme = 0
ValidThemes = []string{
"default", // 0
"nord", // 1
"solarized", // not accurate but whatever
}
ThemesMap = map[int]map[string]string{
2: {
HeaderBackgroundKey: "#268bd2",
HeaderBorderBackgroundKey: "#268bd2",
HeaderBottomColorKey: "#586e75",
BorderColorKey: "#586e75",
TextColorKey: "#fdf6e3",
HeaderForegroundKey: "#fdf6e3",
HighlightKey: "#2aa198",
FooterForegroundColorKey: "#d33682",
HeaderTopForegroundColorKey: "#d33682",
},
1: {
HeaderBackgroundKey: "#5e81ac",
HeaderBorderBackgroundKey: "#5e81ac",
HeaderBottomColorKey: "#5e81ac",
BorderColorKey: "#eceff4",
TextColorKey: "#eceff4",
HeaderForegroundKey: "#eceff4",
HighlightKey: "#88c0d0",
FooterForegroundColorKey: "#b48ead",
HeaderTopForegroundColorKey: "#b48ead",
},
0: {
HeaderBackgroundKey: "#505050",
HeaderBorderBackgroundKey: "#505050",
HeaderBottomColorKey: "#FFFFFF",
BorderColorKey: "#FFFFFF",
TextColorKey: "#FFFFFF",
HeaderForegroundKey: "#FFFFFF",
HighlightKey: "#A0A0A0",
FooterForegroundColorKey: "#C2C2C2",
HeaderTopForegroundColorKey: "#C2C2C2",
},
}
)
================================================
FILE: tuiutil/wordwrap.go
================================================
package tuiutil
import (
"strings"
)
// Indent a string with the given prefix at the start of either the first, or all lines.
//
// input - The input string to indent.
// prefix - The prefix to add.
// prefixAll - If true, prefix all lines with the given prefix.
//
// Example usage:
//
// indented := wordwrap.Indent("Hello\nWorld", "-", true)
func Indent(input string, prefix string, prefixAll bool) string {
lines := strings.Split(input, "\n")
prefixLen := len(prefix)
result := make([]string, len(lines))
for i, line := range lines {
if prefixAll || i == 0 {
result[i] = prefix + line
} else {
result[i] = strings.Repeat(" ", prefixLen) + line
}
}
return strings.Join(result, "\n")
}
================================================
FILE: viewer/defs.go
================================================
package viewer
import (
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/mathaou/termdbms/database"
"github.com/mathaou/termdbms/list"
)
type SQLSnippet struct {
Query string `json:"Query"`
Name string `json:"Name"`
}
type ScrollData struct {
PreScrollYOffset int
PreScrollYPosition int
ScrollXOffset int
}
// TableState holds everything needed to save/serialize state
type TableState struct {
Database database.Database
Data map[string]interface{}
}
type UIState struct {
CanFormatScroll bool
RenderSelection bool // render mode
EditModeEnabled bool // edit mode
FormatModeEnabled bool
BorderToggle bool
SQLEdit bool
ShowClipboard bool
ExpandColumn int
CurrentTable int
}
type UIData struct {
TableHeaders map[string][]string // keeps track of which schema has which headers
TableHeadersSlice []string
TableSlices map[string][]interface{}
TableIndexMap map[int]string // keeps the schemas in order
EditTextBuffer string
}
type FormatState struct {
EditSlices []*string // the bit to show
Text []string // the master collection of lines to edit
RunningOffsets []int // this is a LUT for where in the original EditTextBuffer each line starts
CursorX int
CursorY int
}
// TuiModel holds all the necessary state for this app to work the way I designed it to
type TuiModel struct {
DefaultTable TableState // all non-destructive changes are TableStates getting passed around
DefaultData UIData
QueryResult *TableState
QueryData *UIData
Format FormatState
UI UIState
Scroll ScrollData
Ready bool
InitialFileName string // used if saving destructively
Viewport viewport.Model
ClipboardList list.Model
Clipboard []list.Item
TableStyle lipgloss.Style
MouseData tea.MouseEvent
TextInput LineEdit
FormatInput LineEdit
UndoStack []TableState
RedoStack []TableState
}
================================================
FILE: viewer/events.go
================================================
package viewer
import (
"encoding/json"
"fmt"
"os"
"time"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/mathaou/termdbms/list"
"github.com/mathaou/termdbms/tuiutil"
)
// HandleMouseEvents does that
func HandleMouseEvents(m *TuiModel, msg *tea.MouseMsg) {
switch msg.Type {
case tea.MouseWheelDown:
if !m.UI.EditModeEnabled {
ScrollDown(m)
}
break
case tea.MouseWheelUp:
if !m.UI.EditModeEnabled {
ScrollUp(m)
}
break
case tea.MouseLeft:
if !m.UI.EditModeEnabled && !m.UI.FormatModeEnabled && m.GetRow() < len(m.GetColumnData()) {
SelectOption(m)
}
break
default:
if !m.UI.RenderSelection && !m.UI.EditModeEnabled && !m.UI.FormatModeEnabled {
m.MouseData = tea.MouseEvent(*msg)
}
break
}
}
// HandleWindowSizeEvents does that
func HandleWindowSizeEvents(m *TuiModel, msg *tea.WindowSizeMsg) tea.Cmd {
verticalMargins := HeaderHeight + FooterHeight
if !m.Ready {
width := msg.Width
height := msg.Height
m.Viewport = viewport.Model{
Width: width,
Height: height - verticalMargins}
m.ClipboardList.SetWidth(width)
m.ClipboardList.SetHeight(height)
TUIWidth = width
TUIHeight = height
m.Viewport.YPosition = HeaderHeight
m.Viewport.HighPerformanceRendering = true
m.Ready = true
m.MouseData.Y = HeaderHeight
MaxInputLength = m.Viewport.Width
m.TextInput.Model.CharLimit = -1
m.TextInput.Model.Width = MaxInputLength - lipgloss.Width(m.TextInput.Model.Prompt)
m.TextInput.Model.BlinkSpeed = time.Second
m.TextInput.Model.SetCursorMode(tuiutil.CursorBlink)
m.TableStyle = m.GetBaseStyle()
m.SetViewSlices()
} else {
m.Viewport.Width = msg.Width
m.Viewport.Height = msg.Height - verticalMargins
}
if m.Viewport.HighPerformanceRendering {
return viewport.Sync(m.Viewport)
}
return nil
}
func HandleClipboardEvents(m *TuiModel, str string, command *tea.Cmd, msg tea.Msg) {
state := m.ClipboardList.FilterState()
if (str == "q" || str == "esc" || str == "enter") && state != list.Filtering {
switch str {
case "enter":
i, ok := m.ClipboardList.SelectedItem().(SQLSnippet)
if ok {
ExitToDefaultView(m)
CreatePopulatedBuffer(m, nil, i.Query)
m.UI.SQLEdit = true
}
break
default:
ExitToDefaultView(m)
}
m.ClipboardList.ResetFilter()
} else {
tmpItems := len(m.ClipboardList.Items())
m.ClipboardList, *command = m.ClipboardList.Update(msg)
if len(m.ClipboardList.Items()) != tmpItems { // if item removed
m.Clipboard = m.ClipboardList.Items()
b, _ := json.Marshal(m.Clipboard)
snippetsFile := fmt.Sprintf("%s/%s", HiddenTmpDirectoryName, SQLSnippetsFile)
f, _ := os.OpenFile(snippetsFile, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0775)
f.Write(b)
f.Close()
}
}
}
// HandleKeyboardEvents does that
func HandleKeyboardEvents(m *TuiModel, msg *tea.KeyMsg) tea.Cmd {
var (
cmd tea.Cmd
)
str := msg.String()
if m.UI.EditModeEnabled { // handle edit mode
HandleEditMode(m, str)
return nil
} else if m.UI.FormatModeEnabled {
if str == "esc" { // cycle focus
if m.TextInput.Model.Focused() {
cmd = m.FormatInput.Model.FocusCommand()
m.TextInput.Model.Blur()
} else {
cmd = m.TextInput.Model.FocusCommand()
m.FormatInput.Model.Blur()
}
return cmd
}
if m.TextInput.Model.Focused() {
HandleEditMode(m, str)
} else {
HandleFormatMode(m, str)
}
return nil
}
for k := range GlobalCommands {
if str == k {
return GlobalCommands[str](m)
}
}
return nil
}
================================================
FILE: viewer/global.go
================================================
package viewer
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/mathaou/termdbms/database"
"github.com/mathaou/termdbms/tuiutil"
)
type Command func(m *TuiModel) tea.Cmd
var (
GlobalCommands = make(map[string]Command)
)
func init() {
// GLOBAL COMMANDS
GlobalCommands["t"] = func(m *TuiModel) tea.Cmd {
tuiutil.SelectedTheme = (tuiutil.SelectedTheme + 1) % len(tuiutil.ValidThemes)
SetStyles()
themeName := tuiutil.ValidThemes[tuiutil.SelectedTheme]
m.WriteMessage(fmt.Sprintf("Changed themes to %s", themeName))
return nil
}
GlobalCommands["pgdown"] = func(m *TuiModel) tea.Cmd {
for i := 0; i < m.Viewport.Height; i++ {
ScrollDown(m)
}
return nil
}
GlobalCommands["pgup"] = func(m *TuiModel) tea.Cmd {
for i := 0; i < m.Viewport.Height; i++ {
ScrollUp(m)
}
return nil
}
GlobalCommands["r"] = func(m *TuiModel) tea.Cmd {
if len(m.RedoStack) > 0 && m.QueryResult == nil && m.QueryData == nil { // do this after you get undo working, basically just the same thing reversed
// handle undo
deepCopy := m.CopyMap()
// THE GLOBALIST TAKEOVER
deepState := TableState{
Database: &database.SQLite{
FileName: m.Table().Database.GetFileName(),
Database: nil,
}, // placeholder for now while testing database copy
Data: deepCopy,
}
m.UndoStack = append(m.UndoStack, deepState)
// handle redo
from := m.RedoStack[len(m.RedoStack)-1]
to := m.Table()
m.SwapTableValues(&from, to)
m.Table().Database.CloseDatabaseReference()
m.Table().Database.SetDatabaseReference(from.Database.GetFileName())
m.RedoStack = m.RedoStack[0 : len(m.RedoStack)-1] // pop
}
return nil
}
GlobalCommands["u"] = func(m *TuiModel) tea.Cmd {
if len(m.UndoStack) > 0 && m.QueryResult == nil && m.QueryData == nil {
// handle redo
deepCopy := m.CopyMap()
t := m.Table()
// THE GLOBALIST TAKEOVER
deepState := TableState{
Database: &database.SQLite{
FileName: t.Database.GetFileName(),
Database: nil,
}, // placeholder for now while testing database copy
Data: deepCopy,
}
m.RedoStack = append(m.RedoStack, deepState)
// handle undo
from := m.UndoStack[len(m.UndoStack)-1]
to := t
m.SwapTableValues(&from, to)
t.Database.CloseDatabaseReference()
t.Database.SetDatabaseReference(from.Database.GetFileName())
m.UndoStack = m.UndoStack[0 : len(m.UndoStack)-1] // pop
}
return nil
}
GlobalCommands[":"] = func(m *TuiModel) tea.Cmd {
var (
cmd tea.Cmd
)
if m.QueryData != nil || m.QueryResult != nil { // editing not allowed in query view mode
return nil
}
m.UI.EditModeEnabled = true
raw, _, _ := m.GetSelectedOption()
if raw == nil {
m.UI.EditModeEnabled = false
return nil
}
str := GetStringRepresentationOfInterface(*raw)
// so if the selected text is wider than Viewport width or if it has newlines do format mode
if lipgloss.Width(str+m.TextInput.Model.Prompt) > m.Viewport.Width ||
strings.Count(str, "\n") > 0 { // enter format view
PrepareFormatMode(m)
cmd = m.FormatInput.Model.FocusCommand() // get focus
m.Scroll.PreScrollYOffset = m.Viewport.YOffset // store scrolling so state can be restored on exit
m.Scroll.PreScrollYPosition = m.MouseData.Y
d := m.Data()
if conv, err := FormatJson(str); err == nil { // if json prettify
d.EditTextBuffer = conv
} else {
d.EditTextBuffer = str
}
m.FormatInput.Original = raw // pointer to original data
m.Format.Text = GetFormattedTextBuffer(m)
m.SetViewSlices()
m.FormatInput.Model.SetCursor(0)
} else { // otherwise, edit normally up top
m.TextInput.Model.SetValue(str)
m.FormatInput.Model.Focus = false
m.TextInput.Model.Focus = true
}
return cmd
}
GlobalCommands["p"] = func(m *TuiModel) tea.Cmd {
if m.UI.RenderSelection {
fn, _ := WriteTextFile(m, m.Data().EditTextBuffer)
m.WriteMessage(fmt.Sprintf("Wrote selection to %s", fn))
} else if m.QueryData != nil || m.QueryResult != nil || database.IsCSV {
WriteCSV(m)
}
go Program.Send(tea.KeyMsg{})
return nil
}
GlobalCommands["c"] = func(m *TuiModel) tea.Cmd {
ToggleColumn(m)
return nil
}
GlobalCommands["b"] = func(m *TuiModel) tea.Cmd {
m.UI.BorderToggle = !m.UI.BorderToggle
return nil
}
GlobalCommands["up"] = func(m *TuiModel) tea.Cmd {
if m.UI.CurrentTable == len(m.Data().TableIndexMap) {
m.UI.CurrentTable = 1
} else {
m.UI.CurrentTable++
}
// fix spacing and whatnot
m.TableStyle = m.TableStyle.Width(m.CellWidth())
m.MouseData.Y = HeaderHeight
m.MouseData.X = 0
m.Viewport.YOffset = 0
m.Scroll.ScrollXOffset = 0
return nil
}
GlobalCommands["down"] = func(m *TuiModel) tea.Cmd {
if m.UI.CurrentTable == 1 {
m.UI.CurrentTable = len(m.Data().TableIndexMap)
} else {
m.UI.CurrentTable--
}
// fix spacing and whatnot
m.TableStyle = m.TableStyle.Width(m.CellWidth())
m.MouseData.Y = HeaderHeight
m.MouseData.X = 0
m.Viewport.YOffset = 0
m.Scroll.ScrollXOffset = 0
return nil
}
GlobalCommands["right"] = func(m *TuiModel) tea.Cmd {
headers := m.GetHeaders()
headersLen := len(headers)
if headersLen > maxHeaders && m.Scroll.ScrollXOffset <= headersLen-maxHeaders {
m.Scroll.ScrollXOffset++
}
return nil
}
GlobalCommands["left"] = func(m *TuiModel) tea.Cmd {
if m.Scroll.ScrollXOffset > 0 {
m.Scroll.ScrollXOffset--
}
return nil
}
GlobalCommands["s"] = func(m *TuiModel) tea.Cmd {
max := len(m.GetSchemaData()[m.GetHeaders()[m.GetColumn()]])
if m.MouseData.Y-HeaderHeight+m.Viewport.YOffset < max-1 {
m.MouseData.Y++
ceiling := m.Viewport.Height + HeaderHeight - 1
tuiutil.Clamp(m.MouseData.Y, m.MouseData.Y+1, ceiling)
if m.MouseData.Y > ceiling {
ScrollDown(m)
m.MouseData.Y = ceiling
}
}
return nil
}
GlobalCommands["w"] = func(m *TuiModel) tea.Cmd {
pre := m.MouseData.Y
if m.Viewport.YOffset > 0 && m.MouseData.Y == HeaderHeight {
ScrollUp(m)
m.MouseData.Y = pre
} else if m.MouseData.Y > HeaderHeight {
m.MouseData.Y--
}
return nil
}
GlobalCommands["d"] = func(m *TuiModel) tea.Cmd {
cw := m.CellWidth()
col := m.GetColumn()
cols := len(m.Data().TableHeadersSlice) - 1
if (m.MouseData.X-m.Viewport.Width) <= cw && m.GetColumn() < cols { // within tolerances
m.MouseData.X += cw
} else if col == cols {
return func() tea.Msg {
return tea.KeyMsg{
Type: tea.KeyRight,
Alt: false,
}
}
}
return nil
}
GlobalCommands["a"] = func(m *TuiModel) tea.Cmd {
cw := m.CellWidth()
if m.MouseData.X-cw >= 0 {
m.MouseData.X -= cw
} else if m.GetColumn() == 0 {
return func() tea.Msg {
return tea.KeyMsg{
Type: tea.KeyLeft,
Alt: false,
}
}
}
return nil
}
GlobalCommands["enter"] = func(m *TuiModel) tea.Cmd {
if !m.UI.EditModeEnabled {
SelectOption(m)
}
return nil
}
GlobalCommands["esc"] = func(m *TuiModel) tea.Cmd {
m.TextInput.Model.SetValue("")
if !m.UI.RenderSelection {
m.UI.EditModeEnabled = true
return nil
}
m.UI.RenderSelection = false
m.Data().EditTextBuffer = ""
cmd := m.TextInput.Model.FocusCommand()
m.UI.ExpandColumn = -1
m.MouseData.Y = m.Scroll.PreScrollYPosition
m.Viewport.YOffset = m.Scroll.PreScrollYOffset
return cmd
}
GlobalCommands["k"] = GlobalCommands["up"] // dual bind of up/k
GlobalCommands["j"] = GlobalCommands["down"] // dual bind of down/j
GlobalCommands["l"] = GlobalCommands["right"] // dual bind of right/l
GlobalCommands["h"] = GlobalCommands["left"] // dual bind of left/h
GlobalCommands["m"] = func(m *TuiModel) tea.Cmd {
ScrollUp(m)
return nil
}
GlobalCommands["n"] = func(m *TuiModel) tea.Cmd {
ScrollDown(m)
return nil
}
GlobalCommands["?"] = func(m *TuiModel) tea.Cmd {
help := GetHelpText()
m.DisplayMessage(help)
return nil
}
}
================================================
FILE: viewer/help.go
================================================
package viewer
func GetHelpText() (help string) {
help = `
##### Help:
-p / database path (absolute)
-d / specifies which database driver to use (sqlite/mysql)
-a / enable ascii mode
-h / prints this message
-t / starts app with specific theme (default, nord, solarized)
##### Controls:
###### MOUSE
Scroll up + down to navigate table/text
Move cursor to select cells for full screen viewing
###### KEYBOARD
[WASD] to move around cells, and also move columns if close to edge
[ENTER] to select selected cell for full screen view
[UP/K and DOWN/J] to navigate schemas
[LEFT/H and RIGHT/L] to navigate columns if there are more than the screen allows.
Also to control the cursor of the text editor in edit mode
[BACKSPACE] to delete text before cursor in edit mode
[M(scroll up) and N(scroll down)] to scroll manually
[Q or CTRL+C] to quit program
[B] to toggle borders!
[C] to expand column
[T] to cycle through themes!
[P] in selection mode to write cell to file, or to print query results as CSV.
[R] to redo actions, if applicable
[U] to undo actions, if applicable
[ESC] to exit full screen view, or to enter edit mode
[PGDOWN] to scroll down one views worth of rows
[PGUP] to scroll up one views worth of rows
###### EDIT MODE (for quick, single line changes and commands)
[ESC] to enter edit mode with no pre-loaded text input from selection
When a cell is selected, press [:] to enter edit mode with selection pre-loaded
The text field in the header will be populated with the selected cells text. Modifications can be made freely
[ESC] to clear text field in edit mode
[ENTER] to save text. Anything besides one of the reserved strings below will overwrite the current cell
[:q] to exit edit mode/ format mode/ SQL mode
[:s] to save database to a new file (SQLite only)
[:s!] to overwrite original database file (SQLite only). A confirmation dialog will be added soon
[:h] to display help text
[:new] opens current cell with a blank buffer
[:edit] opens current cell in format mode
[:sql] opens blank buffer for creating an SQL statement
[:clip] to open clipboard of SQL queries. [/] to filter, [ENTER] to select.
[HOME] to set cursor to end of the text
[END] to set cursor to the end of the text
###### FORMAT MODE (for editing lines of text)
[ESC] to move between top control bar and format buffer
[HOME] to set cursor to end of the text
[END] to set cursor to the end of the text
[:wq] to save changes and quit to main table view
[:w] to save changes and remain in format view
[:s] to serialize changes, non-destructive (SQLite only)
[:s!] to serialize changes, overwriting original file (SQLite only)
###### SQL MODE (for querying database)
[ESC] to move between top control bar and text buffer
[:q] to quit out of statement
[:exec] to execute statement. Errors will be displayed in full screen view.
[:stow <NAME>] to create a snippet for the clipboard with an optional name. A random number will be used if no name is specified.
###### QUERY MODE (specifically when viewing query results)
[:d] to reset table data back to original view
[:sql] to query original database again`
return help
}
================================================
FILE: viewer/lineedit.go
================================================
package viewer
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"math/rand"
"os"
"strings"
"time"
"github.com/mathaou/termdbms/database"
"github.com/mathaou/termdbms/tuiutil"
)
const (
QueryResultsTableName = "results"
)
type EnterFunction func(m *TuiModel, selectedInput *tuiutil.TextInputModel, input string)
type LineEdit struct {
Model tuiutil.TextInputModel
Original *interface{}
}
func ExitToDefaultView(m *TuiModel) {
m.UI.RenderSelection = false
m.UI.EditModeEnabled = false
m.UI.FormatModeEnabled = false
m.UI.SQLEdit = false
m.UI.ShowClipboard = false
m.UI.CanFormatScroll = false
m.Format.CursorY = 0
m.Format.CursorX = 0
m.Format.EditSlices = nil
m.Format.Text = nil
m.Format.RunningOffsets = nil
m.FormatInput.Model.Reset()
m.TextInput.Model.Reset()
m.Viewport.YOffset = 0
}
func CreateEmptyBuffer(m *TuiModel, original *interface{}) {
PrepareFormatMode(m)
m.Data().EditTextBuffer = "\n"
m.FormatInput.Original = original
m.Format.Text = GetFormattedTextBuffer(m)
m.SetViewSlices()
m.FormatInput.Model.SetCursor(0)
return
}
func CreatePopulatedBuffer(m *TuiModel, original *interface{}, str string) {
PrepareFormatMode(m)
m.Data().EditTextBuffer = str
m.FormatInput.Original = original
m.Format.Text = GetFormattedTextBuffer(m)
m.SetViewSlices()
m.FormatInput.Model.SetCursor(0)
return
}
func EditEnter(m *TuiModel) {
selectedInput := &m.TextInput.Model
i := selectedInput.Value()
d := m.Data()
t := m.Table()
var (
original *interface{}
input string
)
if i == ":q" { // quit mod mode
ExitToDefaultView(m)
return
}
if !m.UI.FormatModeEnabled && !m.UI.SQLEdit && !m.UI.ShowClipboard {
input = i
raw, _, _ := m.GetSelectedOption()
original = raw
if input == ":d" && m.QueryData != nil && m.QueryResult != nil {
m.DefaultTable.Database.SetDatabaseReference(m.QueryResult.Database.GetFileName())
m.QueryData = nil
m.QueryResult = nil
var c *sql.Rows
defer func() {
if c != nil {
c.Close()
}
}()
err := m.SetModel(c, m.DefaultTable.Database.GetDatabaseReference())
if err != nil {
m.DisplayMessage(fmt.Sprintf("%v", err))
}
ExitToDefaultView(m)
return
}
if m.QueryData != nil {
m.TextInput.Model.SetValue("")
m.WriteMessage("Cannot manipulate database through UI while query results are being displayed.")
return
}
if input == ":h" {
m.DisplayMessage(GetHelpText())
return
} else if input == ":edit" {
str := GetStringRepresentationOfInterface(*original)
PrepareFormatMode(m)
if conv, err := FormatJson(str); err == nil { // if json prettify
d.EditTextBuffer = conv
} else {
d.EditTextBuffer = str
}
m.FormatInput.Original = original
m.Format.Text = GetFormattedTextBuffer(m)
m.SetViewSlices()
m.FormatInput.Model.SetCursor(0)
return
} else if input == ":new" {
CreateEmptyBuffer(m, original)
return
} else if input == ":sql" {
CreateEmptyBuffer(m, original)
m.UI.SQLEdit = true
return
} else if input == ":clip" {
ExitToDefaultView(m)
if len(m.ClipboardList.Items()) == 0 {
return
}
m.UI.ShowClipboard = true
return
}
} else {
input = d.EditTextBuffer
original = m.FormatInput.Original
sqlFlags := m.UI.SQLEdit && !(i == ":exec" || strings.HasPrefix(i, ":stow"))
formatFlags := m.UI.FormatModeEnabled && !(i == ":w" || i == ":wq" || i == ":s" || i == ":s!")
if formatFlags && sqlFlags {
m.TextInput.Model.SetValue("")
return
}
}
if original != nil && *original == input {
ExitToDefaultView(m)
return
}
if i == ":s" { // saves copy, default filename + :s _____ will save with that filename in cwd
ExitToDefaultView(m)
newFileName, err := Serialize(m)
if err != nil {
m.DisplayMessage(fmt.Sprintf("%v", err))
} else {
m.DisplayMessage(fmt.Sprintf("Wrote copy of database to filepath %s.", newFileName))
}
return
} else if i == ":s!" { // overwrites original - should add confirmation dialog!
ExitToDefaultView(m)
err := SerializeOverwrite(m)
if err != nil {
m.DisplayMessage(fmt.Sprintf("%v", err))
} else {
m.DisplayMessage("Overwrote original database file with changes.")
}
return
}
if m.UI.SQLEdit {
if i == ":exec" {
handleSQLMode(m, input)
} else if strings.HasPrefix(i, ":stow") {
if len(input) > 0 {
split := strings.Split(i, " ")
rand.Seed(time.Now().UnixNano())
r := rand.Int()
title := fmt.Sprintf("%d", r) // if no title given then just call it random string
if len(split) == 2 {
title = split[1]
}
m.Clipboard = append(m.Clipboard, SQLSnippet{
Query: input,
Name: title,
})
b, _ := json.Marshal(m.Clipboard)
snippetsFile := fmt.Sprintf("%s/%s", HiddenTmpDirectoryName, SQLSnippetsFile)
f, _ := os.OpenFile(snippetsFile, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0775)
f.Write(b)
f.Close()
m.WriteMessage(fmt.Sprintf("Wrote SQL snippet %s to %s. Total count is %d", title, snippetsFile, len(m.ClipboardList.Items())+1))
}
m.TextInput.Model.SetValue("")
}
return
}
old, n := populateUndo(m)
if old == n || n != m.DefaultTable.Database.GetFileName() {
panic(errors.New("could not get database file name"))
}
if _, err := FormatJson(input); err == nil { // if json uglify
input = strings.ReplaceAll(input, " ", "")
input = strings.ReplaceAll(input, "\n", "")
input = strings.ReplaceAll(input, "\t", "")
input = strings.ReplaceAll(input, "\r", "")
}
u := GetInterfaceFromString(input, original)
database.ProcessSqlQueryForDatabaseType(&database.Update{
Update: u,
}, m.GetRowData(), m.GetSchemaName(), m.GetSelectedColumnName(), &t.Database)
m.UI.EditModeEnabled = false
d.EditTextBuffer = ""
m.FormatInput.Model.SetValue("")
*original = input
if m.UI.FormatModeEnabled && i == ":wq" {
ExitToDefaultView(m)
}
}
func handleSQLMode(m *TuiModel, input string) {
if m.QueryResult != nil {
m.QueryResult = nil
}
m.QueryResult = &TableState{ // perform query
Database: m.Table().Database,
Data: make(map[string]interface{}),
}
m.QueryData = &UIData{}
firstword := strings.ToLower(strings.Split(input, " ")[0])
if exec := firstword == "update" ||
firstword == "delete" ||
firstword == "insert"; exec {
m.QueryData = nil
m.QueryResult = nil
populateUndo(m)
_, err := m.DefaultTable.Database.GetDatabaseReference().Exec(input)
if err != nil {
ExitToDefaultView(m)
m.DisplayMessage(fmt.Sprintf("%v", err))
return
}
var c *sql.Rows
defer func() {
if c != nil {
c.Close()
}
}()
err = m.SetModel(c, m.DefaultTable.Database.GetDatabaseReference())
if err != nil {
m.DisplayMessage(fmt.Sprintf("%v", err))
} else {
ExitToDefaultView(m)
}
} else { // query
c, err := m.QueryResult.Database.GetDatabaseReference().Query(input)
defer func() {
if c != nil {
c.Close()
}
}()
if err != nil {
m.QueryResult = nil
m.QueryData = nil
ExitToDefaultView(m)
m.DisplayMessage(fmt.Sprintf("%v", err))
return
}
i := 0
m.QueryData.TableHeaders = make(map[string][]string)
m.QueryData.TableIndexMap = make(map[int]string)
m.QueryData.TableSlices = make(map[string][]interface{})
m.QueryData.TableHeadersSlice = []string{}
m.PopulateDataForResult(c, &i, QueryResultsTableName)
ExitToDefaultView(m)
m.UI.EditModeEnabled = false
m.UI.CurrentTable = 1
m.Data().EditTextBuffer = ""
m.FormatInput.Model.SetValue("")
}
}
func populateUndo(m *TuiModel) (old string, new string) {
if len(m.UndoStack) >= 10 {
ref := m.UndoStack[len(m.UndoStack)-1]
err := os.Remove(ref.Database.GetFileName())
if err != nil {
fmt.Printf("%v", err)
os.Exit(1)
}
m.UndoStack = m.UndoStack[1:] // need some more complicated logic to handle dereferencing?
}
switch m.DefaultTable.Database.(type) {
case *database.SQLite:
deepCopy := m.CopyMap()
// THE GLOBALIST TAKEOVER
deepState := TableState{
Database: &database.SQLite{
FileName: m.DefaultTable.Database.GetFileName(),
Database: nil,
},
Data: deepCopy,
}
m.UndoStack = append(m.UndoStack, deepState)
old = m.DefaultTable.Database.GetFileName()
dst, _, _ := CopyFile(old)
new = dst
m.DefaultTable.Database.CloseDatabaseReference()
m.DefaultTable.Database.SetDatabaseReference(dst)
break
default:
break
}
return old, new
}
================================================
FILE: viewer/mode.go
================================================
package viewer
import (
"strings"
tea "github.com/charmbracelet/bubbletea"
)
var (
InputBlacklist = []string{
"alt+",
"ctrl+",
"up",
"down",
"tab",
"left",
"enter",
"right",
"pgdown",
"pgup",
}
)
func PrepareFormatMode(m *TuiModel) {
m.UI.FormatModeEnabled = true
m.UI.EditModeEnabled = false
m.TextInput.Model.SetValue("")
m.FormatInput.Model.SetValue("")
m.FormatInput.Model.Focus = true
m.TextInput.Model.Focus = false
m.TextInput.Model.Blur()
}
func MoveCursorWithinBounds(m *TuiModel) {
defer func() {
if recover() != nil {
println("whoopsy")
}
}()
offset := GetOffsetForLineNumber(m.Format.CursorY)
l := len(*m.Format.EditSlices[m.Format.CursorY])
end := l - 1 - offset
if m.Format.CursorX > end {
m.Format.CursorX = end
}
}
func HandleEditInput(m *TuiModel, str, val string) (ret bool) {
selectedInput := &m.TextInput.Model
input := selectedInput.Value()
inputLen := len(input)
if str == "backspace" {
cursor := selectedInput.Cursor()
runes := []rune(input)
if cursor == inputLen && inputLen > 0 {
selectedInput.SetValue(input[0 : inputLen-1])
} else if cursor > 0 {
min := Max(selectedInput.Cursor(), 0)
min = Min(min, inputLen-1)
first := runes[:min-1]
last := runes[min:]
selectedInput.SetValue(string(first) + string(last))
selectedInput.SetCursor(selectedInput.Cursor() - 1)
}
ret = true
} else if str == "enter" { // writes your selection
EditEnter(m)
ret = true
}
return ret
}
func HandleEditMovement(m *TuiModel, str, val string) (ret bool) {
selectedInput := &m.TextInput.Model
if str == "home" {
selectedInput.SetCursor(0)
ret = true
} else if str == "end" {
if len(val) > 0 {
selectedInput.SetCursor(len(val) - 1)
}
ret = true
} else if str == "left" {
cursorPosition := selectedInput.Cursor()
if cursorPosition == selectedInput.Offset && cursorPosition != 0 {
selectedInput.Offset--
selectedInput.OffsetRight--
}
if cursorPosition != 0 {
selectedInput.SetCursor(cursorPosition - 1)
}
ret = true
} else if str == "right" {
cursorPosition := selectedInput.Cursor()
if cursorPosition == selectedInput.OffsetRight {
selectedInput.Offset++
selectedInput.OffsetRight++
}
selectedInput.SetCursor(cursorPosition + 1)
ret = true
}
return ret
}
func HandleFormatMovement(m *TuiModel, str string) (ret bool) {
lines := 0
for _, v := range m.Format.EditSlices {
if *v != "" {
lines++
}
}
switch str {
case "pgdown":
l := len(m.Format.Text) - 1
for i := 0; i < m.Viewport.Height && m.Viewport.YOffset < l; i++ {
ScrollDown(m)
}
ret = true
break
case "pgup":
for i := 0; i <
m.Viewport.Height && m.Viewport.YOffset > 0; i++ {
ScrollUp(m)
}
ret = true
break
case "home":
m.Viewport.YOffset = 0
m.Format.CursorX = 0
m.Format.CursorY = 0
ret = true
break
case "end":
m.Viewport.YOffset = len(m.Format.Text) - m.Viewport.Height
m.Format.CursorY = Min(m.Viewport.Height-FooterHeight, strings.Count(m.Data().EditTextBuffer, "\n"))
m.Format.CursorX = m.Format.RunningOffsets[len(m.Format.RunningOffsets)-1]
ret = true
break
case "right":
ret = true
m.Format.CursorX++
offset := GetOffsetForLineNumber(m.Format.CursorY)
x := m.Format.CursorX + offset + 1 // for the space at the end
l := len(*m.Format.EditSlices[m.Format.CursorY])
maxY := lines - 1
if l < x && m.Format.CursorY < maxY {
m.Format.CursorX = 0
m.Format.CursorY++
} else if l < x && m.Format.CursorY < len(m.Format.Text)-1 {
go Program.Send(
tea.KeyMsg{
Type: tea.KeyDown,
Alt: false,
},
)
} else if m.Format.CursorY > maxY {
m.Format.CursorX = maxY
}
break
case "left":
ret = true
m.Format.CursorX--
if m.Format.CursorX < 0 && m.Format.CursorY > 0 {
m.Format.CursorY--
offset := GetOffsetForLineNumber(m.Format.CursorY)
l := len(*m.Format.EditSlices[m.Format.CursorY])
m.Format.CursorX = l - 1 - offset
} else if m.Format.CursorX < 0 &&
m.Format.CursorY == 0 &&
m.Viewport.YOffset > 0 {
go Program.Send(
tea.KeyMsg{
Type: tea.KeyUp,
Alt: false,
},
)
} else if m.Format.CursorX < 0 {
m.Format.CursorX = 0
}
break
case "up":
ret = true
if m.Format.CursorY > 0 {
m.Format.CursorY--
} else if m.Viewport.YOffset > 0 {
ScrollUp(m)
}
break
case "down":
ret = true
if m.Format.CursorY < m.Viewport.Height-FooterHeight && m.Format.CursorY < lines-1 {
m.Format.CursorY++
} else {
ScrollDown(m)
}
}
return ret
}
func InsertCharacter(m *TuiModel, newlineOrTab string) {
yOffset := Max(m.Viewport.YOffset, 0)
cursor := m.Format.RunningOffsets[m.Format.CursorY+yOffset] + m.Format.CursorX
runes := []rune(m.Data().EditTextBuffer)
min := Max(cursor, 0)
min = Min(min, len(m.Data().EditTextBuffer))
first := runes[:min]
last := runes[min:]
f := string(first)
l := string(last)
m.Data().EditTextBuffer = f + newlineOrTab + l
if len(last) == 0 { // for whatever reason, if you don't double up on newlines if appending to end, it gets removed
m.Data().EditTextBuffer += newlineOrTab
}
numLines := 0
for _, v := range m.Format.Text {
if v != "" { // ignore padding
numLines++
}
}
if yOffset+m.Viewport.Height == numLines && newlineOrTab == "\n" {
m.Viewport.YOffset++
} else if newlineOrTab == "\n" {
m.Format.CursorY++
}
m.Format.Text = GetFormattedTextBuffer(m)
m.SetViewSlices()
if newlineOrTab == "\n" {
m.Format.CursorX = 0
} else {
m.Format.CursorX++
}
}
func HandleFormatInput(m *TuiModel, str string) bool {
switch str {
case "tab":
InsertCharacter(m, "\t")
return true
case "enter":
InsertCharacter(m, "\n")
return true
case "backspace":
cursor := m.Format.CursorX + FormatModeOffset
input := m.Format.EditSlices[m.Format.CursorY]
inputLen := len(*input)
runes := []rune(*input)
if m.Format.CursorX > 0 { // cursor in middle of line
if cursor == inputLen && inputLen > 0 {
*input = (*input)[0 : inputLen-1]
} else if cursor > 0 {
min := Max(cursor, 0)
min = Min(min, inputLen-1)
first := runes[:min-1]
last := runes[min:]
*input = string(first) + string(last)
}
return false
} else if m.Format.CursorY > 0 && m.Format.CursorX == 0 { // beginning of line
yOffset := Max(m.Viewport.YOffset, 0)
cursor := m.Format.RunningOffsets[m.Format.CursorY+yOffset] + m.Format.CursorX
runes := []rune(m.Data().EditTextBuffer)
min := Max(cursor, 0)
min = Min(min, len(m.Data().EditTextBuffer)-1)
first := runes[:min-1]
last := runes[min:]
m.Data().EditTextBuffer = string(first) + string(last)
if yOffset+m.Viewport.Height == len(m.Format.Text) && yOffset > 0 {
m.Viewport.YOffset--
} else {
m.Format.CursorY--
}
m.Format.Text = GetFormattedTextBuffer(m)
m.SetViewSlices()
}
return true
}
return false
}
func HandleFormatMode(m *TuiModel, str string) {
var (
val string
replacement string
)
inputReturn := HandleFormatInput(m, str)
if HandleFormatMovement(m, str) {
return
}
for _, v := range InputBlacklist {
if strings.Contains(str, v) {
return
}
}
lineNumberOffset := GetOffsetForLineNumber(m.Format.CursorY)
pString := m.Format.EditSlices[m.Format.CursorY]
delta := 1
if str != "backspace" {
// update UI
if *pString != "" {
min := Max(m.Format.CursorX+lineNumberOffset+1, 0)
min = Min(min, len(*pString))
first := (*pString)[:min]
last := (*pString)[min:]
val = first + str + last
} else {
val = *pString + str
}
} else {
delta = -1
val = *pString
}
// if json special rules
replacement = m.Data().EditTextBuffer
cursor := m.Format.RunningOffsets[m.Viewport.YOffset+m.Format.CursorY]
fIndex := Max(cursor, 0)
lIndex := m.Viewport.YOffset + m.Format.CursorY + 1
defer func() {
if recover() != nil {
println("whoopsy!") // bug happened once, debug...
}
}()
first := replacement[:fIndex]
middle := val[lineNumberOffset+1:]
last := replacement[Min(m.Format.RunningOffsets[lIndex], len(replacement)):]
if (first != "" || last != "") && last != "\n" {
middle += "\n"
}
replacement = first + // replace the entire line the edit appears on
middle + // insert the edit
last // top the edit off with the rest of the string
m.Data().EditTextBuffer = replacement
if len(*pString) == FormatModeOffset && str != "backspace" { // insert on empty lines behaves funny
*pString = *pString + str
} else {
*pString = val
}
m.Format.CursorX += delta
if inputReturn {
return
}
for i := m.Viewport.YOffset + m.Format.CursorY + 1; i < len(m.Format.RunningOffsets); i++ {
m.Format.RunningOffsets[i] += delta
}
}
// HandleEditMode implementation is kind of jank, but we can clean it up later
func HandleEditMode(m *TuiModel, str string) {
var (
input string
val string
)
selectedInput := &m.TextInput.Model
input = selectedInput.Value()
if input != "" && selectedInput.Cursor() <= len(input)-1 {
min := Max(selectedInput.Cursor(), 0)
min = Min(min, len(input)-1)
first := input[:min]
last := input[min:]
val = first + str + last
} else {
val = input + str
}
if str == "esc" {
selectedInput.SetValue("")
return
}
if HandleEditMovement(m, str, val) || HandleEditInput(m, str, val) {
return
}
for _, v := range InputBlacklist {
if strings.Contains(str, v) {
return
}
}
prePos := selectedInput.Cursor()
if val != "" {
selectedInput.SetValue(val)
} else {
selectedInput.SetValue(str)
}
if prePos != 0 {
prePos = selectedInput.Cursor()
}
selectedInput.SetCursor(prePos + 1)
}
================================================
FILE: viewer/modelutil.go
================================================
package viewer
import (
"database/sql"
"encoding/json"
"fmt"
"os"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/mathaou/termdbms/database"
"github.com/mathaou/termdbms/list"
"github.com/mathaou/termdbms/tuiutil"
)
func (m *TuiModel) WriteMessage(s string) {
if Message == "" {
Message = s
MIP = true
go Program.Send(tea.KeyMsg{}) // trigger update
go Program.Send(tea.KeyMsg{}) // trigger update for sure hack gross but w/e
}
}
func (m *TuiModel) CopyMap() (to map[string]interface{}) {
from := m.Table().Data
to = map[string]interface{}{}
for k, v := range from {
if copyValues, ok := v.(map[string][]interface{}); ok {
columnNames := m.Data().TableHeaders[k]
columnValues := make(map[string][]interface{})
// golang wizardry
columns := make([]interface{}, len(columnNames))
for i := range columns {
columns[i] = copyValues[columnNames[i]]
}
for i, colName := range columnNames {
val := columns[i].([]interface{})
buffer := make([]interface{}, len(val))
for k := range val {
buffer[k] = val[k]
}
columnValues[colName] = append(columnValues[colName], buffer)
}
to[k] = columnValues // data for schema, organized by column
}
}
return to
}
// GetNewModel returns a TuiModel struct with some fields set
func GetNewModel(baseFileName string, db *sql.DB) TuiModel {
m := TuiModel{
DefaultTable: TableState{
Database: &database.SQLite{
FileName: baseFileName,
Database: db,
},
Data: make(map[string]interface{}),
},
Format: FormatState{
EditSlices: nil,
Text: nil,
RunningOffsets: nil,
CursorX: 0,
CursorY: 0,
},
UI: UIState{
CanFormatScroll: false,
RenderSelection: false,
EditModeEnabled: false,
FormatModeEnabled: false,
BorderToggle: false,
CurrentTable: 0,
ExpandColumn: -1,
},
Scroll: ScrollData{},
DefaultData: UIData{
TableHeaders: make(map[string][]string),
TableHeadersSlice: []string{},
TableSlices: make(map[string][]interface{}),
TableIndexMap: make(map[int]string),
},
TextInput: LineEdit{
Model: tuiutil.NewModel(),
},
FormatInput: LineEdit{
Model: tuiutil.NewModel(),
},
Clipboard: []list.Item{},
}
m.FormatInput.Model.Prompt = ""
snippetsFile := fmt.Sprintf("%s/%s", HiddenTmpDirectoryName, SQLSnippetsFile)
exists, _ := Exists(snippetsFile)
if exists {
contents, _ := os.ReadFile(snippetsFile)
var c []SQLSnippet
json.Unmarshal(contents, &c)
for _, v := range c {
m.Clipboard = append(m.Clipboard, v)
}
}
m.ClipboardList = list.NewModel(m.Clipboard, itemDelegate{}, 0, 0)
m.ClipboardList.Title = "SQL Snippets"
m.ClipboardList.SetFilteringEnabled(true)
m.ClipboardList.SetShowPagination(true)
m.ClipboardList.SetShowTitle(true)
return m
}
// SetModel creates a model to be used by bubbletea using some golang wizardry
func (m *TuiModel) SetModel(c *sql.Rows, db *sql.DB) error {
var err error
indexMap := 0
// gets all the schema names of the database
tableNamesQuery := m.Table().Database.GetTableNamesQuery()
rows, err := db.Query(tableNamesQuery)
if err != nil {
return err
}
defer rows.Close()
// for each schema
for rows.Next() {
var schemaName string
rows.Scan(&schemaName)
// couldn't get prepared statements working and gave up because it was very simple
var statement strings.Builder
statement.WriteString("select * from ")
statement.WriteString(schemaName)
getAll := statement.String()
if c != nil {
c.Close()
c = nil
}
c, err = db.Query(getAll)
if err != nil {
panic(err)
}
m.PopulateDataForResult(c, &indexMap, schemaName)
}
// set the first table to be initial view
m.UI.CurrentTable = 1
return nil
}
func (m *TuiModel) PopulateDataForResult(c *sql.Rows, indexMap *int, schemaName string) {
columnNames, _ := c.Columns()
columnValues := make(map[string][]interface{})
for c.Next() { // each row of the table
// golang wizardry
columns := make([]interface{}, len(columnNames))
columnPointers := make([]interface{}, len(columnNames))
// init interface array
for i := range columns {
columnPointers[i] = &columns[i]
}
c.Scan(columnPointers...)
for i, colName := range columnNames {
val := columnPointers[i].(*interface{})
columnValues[colName] = append(columnValues[colName], *val)
}
}
// onto the next schema
*indexMap++
if m.QueryResult != nil && m.QueryData != nil {
m.QueryResult.Data[schemaName] = columnValues
m.QueryData.TableHeaders[schemaName] = columnNames // headers for the schema, for later reference
m.QueryData.TableIndexMap[*indexMap] = schemaName
return
}
m.Table().Data[schemaName] = columnValues // data for schema, organized by column
m.Data().TableHeaders[schemaName] = columnNames // headers for the schema, for later reference
// mapping between schema and an int ( since maps aren't deterministic), for later reference
m.Data().TableIndexMap[*indexMap] = schemaName
}
func (m *TuiModel) SwapTableValues(f, t *TableState) {
from := &f.Data
to := &t.Data
for k, v := range *from {
if copyValues, ok := v.(map[string][]interface{}); ok {
columnNames := m.Data().TableHeaders[k]
columnValues := make(map[string][]interface{})
// golang wizardry
columns := make([]interface{}, len(columnNames))
for i := range columns {
columns[i] = copyValues[columnNames[i]][0]
}
for i, colName := range columnNames {
columnValues[colName] = columns[i].([]interface{})
}
(*to)[k] = columnValues // data for schema, organized by column
}
}
}
================================================
FILE: viewer/serialize.go
================================================
package viewer
import (
"errors"
"fmt"
"log"
"math/rand"
"os"
"path"
"strings"
"github.com/mathaou/termdbms/database"
)
var (
serializationErrorString = fmt.Sprintf("Database driver %s does not support serialization.", database.DriverString)
)
func Serialize(m *TuiModel) (string, error) {
switch m.Table().Database.(type) {
case *database.SQLite:
return SerializeSQLiteDB(m.Table().Database.(*database.SQLite), m), nil
default:
return "", errors.New(serializationErrorString)
}
}
func SerializeOverwrite(m *TuiModel) error {
t := m.Table()
switch t.Database.(type) {
case *database.SQLite:
SerializeOverwriteSQLiteDB(t.Database.(*database.SQLite), m)
return nil
default:
return errors.New(serializationErrorString)
}
}
// SQLITE
func SerializeSQLiteDB(db *database.SQLite, m *TuiModel) string {
db.CloseDatabaseReference()
source, err := os.ReadFile(db.GetFileName())
if err != nil {
panic(err)
}
ext := path.Ext(m.InitialFileName)
newFileName := fmt.Sprintf("%s-%d%s", strings.TrimSuffix(m.InitialFileName, ext), rand.Intn(4), ext)
err = os.WriteFile(newFileName, source, 0777)
if err != nil {
log.Fatal(err)
}
db.SetDatabaseReference(db.GetFileName())
return newFileName
}
func SerializeOverwriteSQLiteDB(db *database.SQLite, m *TuiModel) {
db.CloseDatabaseReference()
filename := db.GetFileName()
source, err := os.ReadFile(filename)
if err != nil {
panic(err)
}
err = os.WriteFile(m.InitialFileName, source, 0777)
if err != nil {
log.Fatal(err)
}
db.SetDatabaseReference(filename)
}
================================================
FILE: viewer/snippets.go
================================================
package viewer
import (
"fmt"
"io"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/mathaou/termdbms/list"
"github.com/mathaou/termdbms/tuiutil"
)
var (
style = lipgloss.NewStyle()
)
func (s SQLSnippet) Title() string {
return s.Name
}
func (s SQLSnippet) Description() string {
return s.Query
}
func (s SQLSnippet) FilterValue() string {
return s.Name
}
type itemDelegate struct{}
func (d itemDelegate) Height() int { return 1 }
func (d itemDelegate) Spacing() int { return 0 }
func (d itemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd {
return nil
}
func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
localStyle := style.Copy()
i, ok := listItem.(SQLSnippet)
if !ok {
return
}
digits := len(fmt.Sprintf("%d", len(m.Items()))) + 1
incomingDigits := len(fmt.Sprintf("%d", index+1))
if !tuiutil.Ascii {
localStyle = style.Copy().Faint(true)
}
str := fmt.Sprintf("%d) %s%s | ", index+1, strings.Repeat(" ", digits-incomingDigits),
i.Title())
query := localStyle.Render(i.Query[0:Min(TUIWidth-10, Max(len(i.Query)-1, len(i.Query)-1-len(str)))]) // padding + tab + padding
str += strings.ReplaceAll(query, "\n", "")
localStyle = style.Copy().PaddingLeft(4)
fn := localStyle.Render
if index == m.Index() {
fn = func(s string) string {
localStyle = style.Copy().
PaddingLeft(2)
if !tuiutil.Ascii {
localStyle = localStyle.
Foreground(lipgloss.Color(tuiutil.HeaderTopForeground()))
}
return lipgloss.JoinHorizontal(lipgloss.Left,
localStyle.
Render("> "),
style.Render(s))
}
}
fmt.Fprintf(w, fn(str))
}
================================================
FILE: viewer/table.go
================================================
package viewer
import (
"fmt"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/mathaou/termdbms/database"
"github.com/mathaou/termdbms/tuiutil"
)
type TableAssembly func(m *TuiModel, s *string, c *chan bool)
var (
HeaderAssembly TableAssembly
FooterAssembly TableAssembly
Message string
mid *string
MIP bool
)
func init() {
tmp := ""
MIP = false
mid = &tmp
HeaderAssembly = func(m *TuiModel, s *string, done *chan bool) {
if m.UI.ShowClipboard {
*done <- true
return
}
var (
builder []string
)
style := m.GetBaseStyle()
if !tuiutil.Ascii {
// for column headers
style = style.Foreground(lipgloss.Color(tuiutil.HeaderForeground())).
BorderBackground(lipgloss.Color(tuiutil.HeaderBorderBackground())).
Background(lipgloss.Color(tuiutil.HeaderBackground()))
}
headers := m.Data().TableHeadersSlice
for i, d := range headers { // write all headers
if m.UI.ExpandColumn != -1 && i != m.UI.ExpandColumn {
continue
}
text := " " + TruncateIfApplicable(m, d)
builder = append(builder, style.
Render(text))
}
{
// schema name
var headerTop string
if m.UI.EditModeEnabled || m.UI.FormatModeEnabled {
headerTop = m.TextInput.Model.View()
if !m.TextInput.Model.Focused() {
headerTop = HeaderStyle.Copy().Faint(true).Render(headerTop)
}
} else {
headerTop = fmt.Sprintf(" %s (%d/%d) - %d record(s) + %d column(s)",
m.GetSchemaName(),
m.UI.CurrentTable,
len(m.Data().TableHeaders), // look at how headers get rendered to get accurate record number
len(m.GetColumnData()),
len(m.GetHeaders())) // this will need to be refactored when filters get added
headerTop = HeaderStyle.Render(headerTop)
}
headerMid := lipgloss.JoinHorizontal(lipgloss.Left, builder...)
if m.UI.RenderSelection {
headerMid = ""
}
*s = lipgloss.JoinVertical(lipgloss.Left, headerTop, headerMid)
}
*done <- true
}
FooterAssembly = func(m *TuiModel, s *string, done *chan bool) {
if m.UI.ShowClipboard {
*done <- true
return
}
var (
row int
col int
)
if !m.UI.FormatModeEnabled { // reason we flip is because it makes more sense to store things by column for data
row = m.GetRow() + m.Viewport.YOffset
col = m.GetColumn() + m.Scroll.ScrollXOffset
} else { // but for format mode thats just a regular row/col situation
row = m.Format.CursorX
col = m.Format.CursorY + m.Viewport.YOffset
}
footer := fmt.Sprintf(" %d, %d ", row, col)
if m.UI.RenderSelection {
footer = ""
}
undoRedoInfo := fmt.Sprintf(" undo(%d) / redo(%d) ", len(m.UndoStack), len(m.RedoStack))
switch m.Table().Database.(type) {
case *database.SQLite:
break
default:
undoRedoInfo = ""
break
}
gapSize := m.Viewport.Width - lipgloss.Width(footer) - lipgloss.Width(undoRedoInfo) - 2
if MIP {
MIP = false
if !tuiutil.Ascii {
Message = FooterStyle.Render(Message)
}
go func() {
newSize := gapSize - lipgloss.Width(Message)
if newSize < 1 {
newSize = 1
}
half := strings.Repeat("-", newSize/2)
if lipgloss.Width(Message) > gapSize {
Message = Message[0:gapSize-3] + "..."
}
*mid = half + Message + half
time.Sleep(time.Second * 5)
Message = ""
go Program.Send(tea.KeyMsg{})
}()
} else if Message == "" {
*mid = strings.Repeat("-", gapSize)
}
queryResultsFlag := "├"
if m.QueryData != nil || m.QueryResult != nil {
queryResultsFlag = "*"
}
footer = FooterStyle.Render(undoRedoInfo) + queryResultsFlag + *mid + "┤" + FooterStyle.Render(footer)
*s = footer
*done <- true
}
}
================================================
FILE: viewer/tableutil.go
================================================
package viewer
import (
"errors"
"github.com/charmbracelet/lipgloss"
"github.com/mathaou/termdbms/tuiutil"
)
var maxHeaders int
// AssembleTable shows either the selection text or the table
func AssembleTable(m *TuiModel) string {
if m.UI.ShowClipboard {
return ShowClipboard(m)
}
if m.UI.RenderSelection {
return DisplaySelection(m)
}
if m.UI.FormatModeEnabled {
return DisplayFormatText(m)
}
return DisplayTable(m)
}
// NumHeaders gets the number of columns for the current schema
func (m *TuiModel) NumHeaders() int {
headers := m.GetHeaders()
l := len(headers)
if m.UI.ExpandColumn > -1 || l == 0 {
return 1
}
maxHeaders = 7
if l > maxHeaders { // this just looked the best after some trial and error
if l%5 == 0 {
return 5
} else if l%4 == 0 {
return 4
} else if l%3 == 0 {
return 3
} else {
return 6 // primes and shiiiii
}
}
return l
}
// CellWidth gets the current cell width for schema
func (m *TuiModel) CellWidth() int {
h := m.NumHeaders()
return m.Viewport.Width/h + 2
}
// GetBaseStyle returns a new style that is used everywhere
func (m *TuiModel) GetBaseStyle() lipgloss.Style {
cw := m.CellWidth()
s := lipgloss.NewStyle().
Foreground(lipgloss.Color(tuiutil.TextColor())).
Width(cw).
Align(lipgloss.Left)
if m.UI.BorderToggle && !tuiutil.Ascii {
s = s.BorderLeft(true).
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color(tuiutil.BorderColor()))
}
return s
}
// GetColumn gets the column the mouse cursor is in
func (m *TuiModel) GetColumn() int {
baseVal := m.MouseData.X / m.CellWidth()
if m.UI.RenderSelection || m.UI.EditModeEnabled || m.UI.FormatModeEnabled {
return m.Scroll.ScrollXOffset + baseVal
}
return baseVal
}
// GetRow does math to get a valid row that's helpful
func (m *TuiModel) GetRow() int {
baseVal := Max(m.MouseData.Y-HeaderHeight, 0)
if m.UI.RenderSelection || m.UI.EditModeEnabled {
return m.Viewport.YOffset + baseVal
} else if m.UI.FormatModeEnabled {
return m.Scroll.PreScrollYOffset + baseVal
}
return baseVal
}
// GetSchemaName gets the current schema name
func (m *TuiModel) GetSchemaName() string {
return m.Data().TableIndexMap[m.UI.CurrentTable]
}
// GetHeaders does just that for the current schema
func (m *TuiModel) GetHeaders() []string {
schema := m.GetSchemaName()
d := m.Data()
return d.TableHeaders[schema]
}
func (m *TuiModel) SetViewSlices() {
d := m.Data()
if m.Viewport.Height < 0 {
return
}
if m.UI.FormatModeEnabled {
var slices []*string
for i := 0; i < m.Viewport.Height; i++ {
yOffset := Max(m.Viewport.YOffset, 0)
if yOffset+i > len(m.Format.Text)-1 {
break
}
pStr := &m.Format.Text[Max(yOffset+i, 0)]
slices = append(slices, pStr)
}
m.Format.EditSlices = slices
m.UI.CanFormatScroll = len(m.Format.Text)-m.Viewport.YOffset-m.Viewport.Height > 0
if m.Format.CursorX < 0 {
m.Format.CursorX = 0
}
} else {
// header slices
headers := d.TableHeaders[m.GetSchemaName()]
headersLen := len(headers)
if headersLen > maxHeaders {
headers = headers[m.Scroll.ScrollXOffset : maxHeaders+m.Scroll.ScrollXOffset-1]
}
// data slices
defer func() {
if recover() != nil {
panic(errors.New("adsf"))
}
}()
for _, columnName := range headers {
interfaceValues := m.GetSchemaData()[columnName]
if len(interfaceValues) >= m.Viewport.Height {
min := Min(m.Viewport.YOffset, len(interfaceValues)-m.Viewport.Height)
d.TableSlices[columnName] = interfaceValues[min : m.Viewport.Height+min]
} else {
d.TableSlices[columnName] = interfaceValues
}
}
d.TableHeadersSlice = headers
}
// format slices
}
// GetSchemaData is a helper function to get the data of the current schema
func (m *TuiModel) GetSchemaData() map[string][]interface{} {
n := m.GetSchemaName()
t := m.Table()
d := t.Data
if d[n] == nil {
return map[string][]interface{}{}
}
return d[n].(map[string][]interface{})
}
func (m *TuiModel) GetSelectedColumnName() string {
col := m.GetColumn()
headers := m.GetHeaders()
index := Min(m.NumHeaders()-1, col)
if len(headers) == 0 {
return ""
}
return headers[index]
}
func (m *TuiModel) GetColumnData() []interface{} {
schemaData := m.GetSchemaData()
if schemaData == nil {
return []interface{}{}
}
return schemaData[m.GetSelectedColumnName()]
}
func (m *TuiModel) GetRowData() map[string]interface{} {
defer func() {
if recover() != nil {
println("Whoopsy!") // TODO, this happened once
}
}()
headers := m.GetHeaders()
schema := m.GetSchemaData()
data := make(map[string]interface{})
for _, v := range headers {
data[v] = schema[v][m.GetRow()]
}
return data
}
func (m *TuiModel) GetSelectedOption() (*interface{}, int, []interface{}) {
if !m.UI.FormatModeEnabled {
m.Scroll.PreScrollYOffset = m.Viewport.YOffset
m.Scroll.PreScrollYPosition = m.MouseData.Y
}
row := m.GetRow()
col := m.GetColumnData()
if row >= len(col) {
return nil, row, col
}
return &col[row], row, col
}
func (m *TuiModel) DisplayMessage(msg string) {
m.Data().EditTextBuffer = msg
m.UI.EditModeEnabled = false
m.UI.RenderSelection = true
}
func (m *TuiModel) GetSelectedLineEdit() *LineEdit {
if m.TextInput.Model.Focused() {
return &m.TextInput
}
return &m.FormatInput
}
func ToggleColumn(m *TuiModel) {
if m.UI.ExpandColumn > -1 {
m.UI.ExpandColumn = -1
} else {
m.UI.ExpandColumn = m.GetColumn()
}
}
================================================
FILE: viewer/ui.go
================================================
package viewer
import (
"fmt"
"strconv"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/mathaou/termdbms/tuiutil"
"github.com/muesli/reflow/wordwrap"
)
var (
Program *tea.Program
FormatModeOffset int
TUIWidth int
TUIHeight int
)
func GetOffsetForLineNumber(a int) int {
return FormatModeOffset - len(strconv.Itoa(a))
}
func SelectOption(m *TuiModel) {
if m.UI.RenderSelection {
return
}
m.UI.RenderSelection = true
raw, _, col := m.GetSelectedOption()
if raw == nil {
return
}
l := len(col)
row := m.Viewport.YOffset + m.MouseData.Y - HeaderHeight
if row <= l && l > 0 &&
m.MouseData.Y >= HeaderHeight &&
m.MouseData.Y < m.Viewport.Height+HeaderHeight &&
m.MouseData.X < m.CellWidth()*(len(m.Data().TableHeadersSlice)) {
if conv, ok := (*raw).(string); ok {
m.Data().EditTextBuffer = conv
} else {
m.Data().EditTextBuffer = ""
}
} else {
m.UI.RenderSelection = false
}
}
// ScrollDown is a simple function to move the Viewport down
func ScrollDown(m *TuiModel) {
if m.UI.FormatModeEnabled && m.UI.CanFormatScroll && m.Viewport.YPosition != 0 {
m.Viewport.YOffset++
return
}
max := GetScrollDownMaximumForSelection(m)
if m.Viewport.YOffset < max-m.Viewport.Height {
m.Viewport.YOffset++
m.MouseData.Y = Min(m.MouseData.Y, m.Viewport.YOffset)
}
if !m.UI.RenderSelection {
m.Scroll.PreScrollYPosition = m.MouseData.Y
m.Scroll.PreScrollYOffset = m.Viewport.YOffset
}
}
// ScrollUp is a simple function to move the Viewport up
func ScrollUp(m *TuiModel) {
if m.UI.FormatModeEnabled && m.UI.CanFormatScroll && m.Viewport.YOffset > 0 && m.Viewport.YPosition != 0 {
m.Viewport.YOffset--
return
}
if m.Viewport.YOffset > 0 {
m.Viewport.YOffset--
m.MouseData.Y = Min(m.MouseData.Y, m.Viewport.YOffset)
} else {
m.MouseData.Y = HeaderHeight
}
if !m.UI.RenderSelection {
m.Scroll.PreScrollYPosition = m.MouseData.Y
m.Scroll.PreScrollYOffset = m.Viewport.YOffset
}
}
// TABLE STUFF
// DisplayTable does some fancy stuff to get a table rendered in text
func DisplayTable(m *TuiModel) string {
var (
builder []string
)
// go through all columns
for c, columnName := range m.Data().TableHeadersSlice {
if m.UI.ExpandColumn > -1 && m.UI.ExpandColumn != c {
continue
}
var (
rowBuilder []string
)
columnValues := m.Data().TableSlices[columnName]
for r, val := range columnValues {
base := m.GetBaseStyle().
UnsetBorderLeft().
UnsetBorderStyle().
UnsetBorderForeground()
s := GetStringRepresentationOfInterface(val)
s = " " + s
// handle highlighting
if c == m.GetColumn() && r == m.GetRow() {
if !tuiutil.Ascii {
base.Foreground(lipgloss.Color(tuiutil.Highlight()))
} else if tuiutil.Ascii {
s = "|" + s
}
}
// display text based on type
rowBuilder = append(rowBuilder, base.Render(TruncateIfApplicable(m, s)))
}
for len(rowBuilder) < m.Viewport.Height { // fix spacing issues
rowBuilder = append(rowBuilder, "")
}
column := lipgloss.JoinVertical(lipgloss.Left, rowBuilder...)
// get a list of columns
builder = append(builder, m.GetBaseStyle().Render(column))
}
// join them into rows
return lipgloss.JoinHorizontal(lipgloss.Left, builder...)
}
func GetFormattedTextBuffer(m *TuiModel) []string {
v := m.Data().EditTextBuffer
lines := SplitLines(v)
FormatModeOffset = len(strconv.Itoa(len(lines))) + 1 // number of characters in the numeric string
var ret []string
m.Format.RunningOffsets = []int{}
total := 0
strlen := 0
for i, v := range lines {
xOffset := len(strconv.Itoa(i))
totalOffset := Max(FormatModeOffset-xOffset, 0)
//wrap := wordwrap.String(v, m.Viewport.Width-totalOffset)
right := tuiutil.Indent(
v,
fmt.Sprintf("%d%s", i, strings.Repeat(" ", totalOffset)),
false)
ret = append(ret, right)
m.Format.RunningOffsets = append(m.Format.RunningOffsets, total)
strlen = len(v)
total += strlen + 1
}
lineLength := len(ret)
// need to add this so that the last line can be edited
m.Format.RunningOffsets = append(m.Format.RunningOffsets,
m.Format.RunningOffsets[lineLength-1]+
len(ret[len(ret)-1][FormatModeOffset:]))
for i := len(ret); i < m.Viewport.Height; i++ {
ret = append(ret, "")
}
return ret
}
func DisplayFormatText(m *TuiModel) string {
cpy := make([]string, len(m.Format.EditSlices))
for i, v := range m.Format.EditSlices {
cpy[i] = *v
}
newY := ""
line := &cpy[Min(m.Format.CursorY, len(cpy)-1)]
x := 0
offset := FormatModeOffset - 1
for _, r := range *line {
newY += string(r)
if x == m.Format.CursorX+offset {
x++
break
}
x++
}
*line += " " // space at the end
highlight := string((*line)[x])
if tuiutil.Ascii {
highlight = "|" + highlight
newY += highlight
} else {
newY += lipgloss.NewStyle().Background(lipgloss.Color("#ffffff")).Render(highlight)
}
newY += (*line)[x+1:]
*line = newY
ret := strings.Join(
cpy,
"\n")
return wordwrap.String(ret, m.Viewport.Width)
}
func ShowClipboard(m *TuiModel) string {
return m.ClipboardList.View()
}
// DisplaySelection does that or writes it to a file if the selection is over a limit
func DisplaySelection(m *TuiModel) string {
col := m.GetColumnData()
row := m.GetRow()
m.UI.ExpandColumn = m.GetColumn()
if m.MouseData.Y >= m.Viewport.Height+HeaderHeight &&
!m.UI.RenderSelection { // this is for when the selection is outside the bounds
return DisplayTable(m)
}
base := m.GetBaseStyle()
if m.Data().EditTextBuffer != "" { // this is basically just if its a string follow these rules
conv := m.Data().EditTextBuffer
if c, err := FormatJson(m.Data().EditTextBuffer); err == nil {
conv = c
}
rows := SplitLines(wordwrap.String(conv, m.Viewport.Width))
min := 0
if len(rows) > m.Viewport.Height {
min = m.Viewport.YOffset
}
max := min + m.Viewport.Height
rows = rows[min:Min(len(rows), max)]
for len(rows) < m.Viewport.Height {
rows = append(rows, "")
}
return base.Render(lipgloss.JoinVertical(lipgloss.Left, rows...))
}
var prettyPrint string
raw := col[row]
if conv, ok := raw.(int64); ok {
prettyPrint = strconv.Itoa(int(conv))
} else if i, ok := raw.(float64); ok {
prettyPrint = base.Render(fmt.Sprintf("%.2f", i))
} else if t, ok := raw.(time.Time); ok {
str := t.String()
prettyPrint = base.Render(str)
} else if raw == nil {
prettyPrint = base.Render("NULL")
}
lines := SplitLines(prettyPrint)
for len(lines) < m.Viewport.Height {
lines = append(lines, "")
}
prettyPrint = " " + base.Render(lipgloss.JoinVertical(lipgloss.Left, lines...))
return wordwrap.String(prettyPrint, m.Viewport.Width)
}
================================================
FILE: viewer/util.go
================================================
package viewer
import (
"bufio"
"bytes"
"encoding/json"
"errors"
"fmt"
"hash/fnv"
"io"
"math/rand"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/charmbracelet/lipgloss"
)
const (
HiddenTmpDirectoryName = ".termdbms"
SQLSnippetsFile = "snippets.termdbms"
)
func TruncateIfApplicable(m *TuiModel, conv string) (s string) {
max := 0
viewportWidth := m.Viewport.Width
cellWidth := m.CellWidth()
if m.UI.RenderSelection || m.UI.ExpandColumn > -1 {
max = viewportWidth
} else {
max = cellWidth
}
if strings.Count(conv, "\n") > 0 {
conv = SplitLines(conv)[0]
}
textWidth := lipgloss.Width(conv)
minVal := Min(textWidth, max)
if max == minVal && textWidth >= max { // truncate
s = conv[:minVal]
s = s[:lipgloss.Width(s)-3] + "..."
} else {
s = conv
}
return s
}
func GetInterfaceFromString(str string, original *interface{}) interface{} {
switch (*original).(type) {
case bool:
bVal, _ := strconv.ParseBool(str)
return bVal
case int64:
iVal, _ := strconv.ParseInt(str, 10, 64)
return iVal
case int32:
iVal, _ := strconv.ParseInt(str, 10, 64)
return iVal
case float64:
fVal, _ := strconv.ParseFloat(str, 64)
return fVal
case float32:
fVal, _ := strconv.ParseFloat(str, 64)
return fVal
case time.Time:
t := (*original).(time.Time)
return t // TODO figure out how to handle things like time and date
case string:
return str
}
return nil
}
func GetStringRepresentationOfInterface(val interface{}) string {
if str, ok := val.(string); ok {
return str
} else if i, ok := val.(int64); ok { // these default to int64 so not sure how this would affect 32 bit systems TODO
return fmt.Sprintf("%d", i)
} else if i, ok := val.(int32); ok { // these default to int32 so not sure how this would affect 32 bit systems TODO
return fmt.Sprintf("%d", i)
} else if i, ok := val.(float64); ok {
return fmt.Sprintf("%.2f", i)
} else if i, ok := val.(float32); ok {
return fmt.Sprintf("%.2f", i)
} else if t, ok := val.(time.Time); ok {
str := t.String()
return str
} else if val == nil {
return "NULL"
}
return ""
}
func WriteCSV(m *TuiModel) { // basically display table but without any styling
if m.QueryData == nil || m.QueryResult == nil {
return // should never happen but just making sure
}
var (
builder [][]string
buffer strings.Builder
)
d := m.Data()
// go through all columns
for _, columnName := range d.TableHeaders[QueryResultsTableName] {
var (
rowBuilder []string
)
columnValues := m.GetSchemaData()[columnName]
rowBuilder = append(rowBuilder, columnName)
for _, val := range columnValues {
s := GetStringRepresentationOfInterface(val)
// display text based on type
rowBuilder = append(rowBuilder, s)
}
builder = append(builder, rowBuilder)
}
depth := len(builder[0])
headers := len(builder)
for i := 0; i < depth; i++ {
var r []string
for x := 0; x < headers; x++ {
r = append(r, builder[x][i])
}
buffer.WriteString(strings.Join(r, ","))
buffer.WriteString("\n")
}
WriteTextFile(m, buffer.String())
}
func WriteTextFile(m *TuiModel, text string) (string, error) {
rand.Seed(time.Now().Unix())
fileName := m.GetSchemaName() + "_" + "renderView_" + fmt.Sprintf("%d", rand.Int()) + ".txt"
e := os.WriteFile(fileName, []byte(text), 0777)
return fileName, e
}
// IsUrl is some code I stole off stackoverflow to validate paths
func IsUrl(fp string) bool {
// Check if file already exists
if _, err := os.Stat(fp); err == nil {
return true
}
// Attempt to create it
var d []byte
if err := os.WriteFile(fp, d, 0644); err == nil {
os.Remove(fp) // And delete it
return true
}
return false
}
func FileExists(name string) (bool, error) {
_, err := os.Stat(name)
if err == nil {
return false, nil
}
if errors.Is(err, os.ErrNotExist) {
return true, nil
}
return true, err
}
func SplitLines(s string) []string {
var lines []string
if strings.Count(s, "\n") == 0 {
return append(lines, s)
}
reader := strings.NewReader(s)
sc := bufio.NewScanner(reader)
for sc.Scan() {
lines = append(lines, sc.Text())
}
return lines
}
func GetScrollDownMaximumForSelection(m *TuiModel) int {
max := 0
if m.UI.RenderSelection {
conv, _ := FormatJson(m.Data().EditTextBuffer)
lines := SplitLines(conv)
max = len(lines)
} else if m.UI.FormatModeEnabled {
max = len(SplitLines(DisplayFormatText(m)))
} else {
return len(m.GetColumnData())
}
return max
}
// FormatJson is some more code I stole off stackoverflow
func FormatJson(str string) (string, error) {
b := []byte(str)
if !json.Valid(b) { // return original string if not json
return str, errors.New("this is not valid JSON")
}
var formattedJson bytes.Buffer
if err := json.Indent(&formattedJson, b, "", " "); err != nil {
return "", err
}
return formattedJson.String(), nil
}
func Exists(path string) (bool, error) {
_, err := os.Stat(path)
if err == nil {
return true, nil
}
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
func Hash(s string) uint32 {
h := fnv.New32a()
h.Write([]byte(s))
return h.Sum32()
}
func CopyFile(src string) (string, int64, error) {
sourceFileStat, err := os.Stat(src)
rand.Seed(time.Now().UnixNano())
dst := fmt.Sprintf(".%d",
Hash(fmt.Sprintf("%s%d",
src,
rand.Uint64())))
if err != nil {
return "", 0, err
}
if !sourceFileStat.Mode().IsRegular() {
return "", 0, fmt.Errorf("%s is not a regular file", src)
}
source, err := os.Open(src)
if err != nil {
return "", 0, err
}
defer source.Close()
destination, err := os.CreateTemp(HiddenTmpDirectoryName, dst)
if err != nil {
return "", 0, err
}
defer destination.Close()
nBytes, err := io.Copy(destination, source)
info, _ := destination.Stat()
path, _ := filepath.Abs(
fmt.Sprintf("%s/%s",
HiddenTmpDirectoryName,
info.Name())) // platform agnostic
return path, nBytes, err
}
// MATH YO
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 Abs(a int) int {
if a < 0 {
return a * -1
}
return a
}
================================================
FILE: viewer/viewer.go
================================================
package viewer
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/mathaou/termdbms/list"
"github.com/mathaou/termdbms/tuiutil"
)
var (
HeaderHeight = 2
FooterHeight = 1
MaxInputLength int
HeaderStyle lipgloss.Style
FooterStyle lipgloss.Style
HeaderDividerStyle lipgloss.Style
InitialModel *TuiModel
)
func (m *TuiModel) Data() *UIData {
if m.QueryData != nil {
return m.QueryData
}
return &m.DefaultData
}
func (m *TuiModel) Table() *TableState {
if m.QueryResult != nil {
return m.QueryResult
}
return &m.DefaultTable
}
func SetStyles() {
HeaderStyle = lipgloss.NewStyle()
FooterStyle = lipgloss.NewStyle()
HeaderDividerStyle = lipgloss.NewStyle().
Align(lipgloss.Center)
if !tuiutil.Ascii {
HeaderStyle = HeaderStyle.
Foreground(lipgloss.Color(tuiutil.HeaderTopForeground()))
FooterStyle = FooterStyle.
Foreground(lipgloss.Color(tuiutil.FooterForeground()))
HeaderDividerStyle = HeaderDividerStyle.
Foreground(lipgloss.Color(tuiutil.HeaderBottom()))
}
}
// INIT UPDATE AND RENDER
// Init currently doesn't do anything but necessary for interface adherence
func (m TuiModel) Init() tea.Cmd {
SetStyles()
return nil
}
// Update is where all commands and whatnot get processed
func (m TuiModel) Update(message tea.Msg) (tea.Model, tea.Cmd) {
var (
command tea.Cmd
commands []tea.Cmd
)
if !m.UI.FormatModeEnabled {
m.Viewport, _ = m.Viewport.Update(message)
}
switch msg := message.(type) {
case list.FilterMatchesMessage:
m.ClipboardList, command = m.ClipboardList.Update(msg)
break
case tea.MouseMsg:
HandleMouseEvents(&m, &msg)
m.SetViewSlices()
break
case tea.WindowSizeMsg:
event := HandleWindowSizeEvents(&m, &msg)
if event != nil {
commands = append(commands, event)
}
break
case tea.KeyMsg:
str := msg.String()
if m.UI.ShowClipboard {
HandleClipboardEvents(&m, str, &command, msg)
break
}
// when fullscreen selection viewing is in session, don't allow UI manipulation other than quit or exit
s := msg.String()
invalidRenderCommand := m.UI.RenderSelection &&
s != "esc" &&
s != "ctrl+c" &&
s != "q" &&
s != "p" &&
s != "m" &&
s != "n"
if invalidRenderCommand {
break
}
if s == "ctrl+c" || (s == "q" && (!m.UI.EditModeEnabled && !m.UI.FormatModeEnabled)) {
return m, tea.Quit
}
event := HandleKeyboardEvents(&m, &msg)
if event != nil {
commands = append(commands, event)
}
if !m.UI.EditModeEnabled && m.Ready {
m.SetViewSlices()
if m.UI.FormatModeEnabled {
MoveCursorWithinBounds(&m)
}
}
break
case error:
return m, nil
}
if m.Viewport.HighPerformanceRendering {
commands = append(commands, command)
}
return m, tea.Batch(commands...)
}
// View is where all rendering happens
func (m TuiModel) View() string {
if !m.Ready || m.Viewport.Width == 0 {
return "\n\tInitializing..."
}
// this ensures that all 3 parts can be worked on concurrently(ish)
done := make(chan bool, 3)
defer close(done) // close
var footer, header, content string
// body
go func(c *string) {
*c = AssembleTable(&m)
done <- true
}(&content)
if m.UI.ShowClipboard {
<-done
return content
}
// header
go HeaderAssembly(&m, &header, &done)
// footer (shows row/col for now)
go FooterAssembly(&m, &footer, &done)
// block until all 3 done
<-done
<-done
<-done
return fmt.Sprintf("%s\n%s\n%s", header, content, footer) // render
}
gitextract_ax31ssze/
├── .github/
│ └── workflows/
│ └── codeql-analysis.yml
├── .gitignore
├── CHANGELOG.txt
├── LICENSE
├── README.md
├── database/
│ ├── query.go
│ └── sqlite.go
├── go.mod
├── go.sum
├── list/
│ ├── defaultitem.go
│ ├── keys.go
│ ├── list.go
│ └── style.go
├── main.go
├── tuiutil/
│ ├── csv2sql.go
│ ├── textinput.go
│ ├── theme.go
│ └── wordwrap.go
└── viewer/
├── defs.go
├── events.go
├── global.go
├── help.go
├── lineedit.go
├── mode.go
├── modelutil.go
├── serialize.go
├── snippets.go
├── table.go
├── tableutil.go
├── ui.go
├── util.go
└── viewer.go
SYMBOL INDEX (292 symbols across 25 files)
FILE: database/query.go
function init (line 15) | func init() {
type Query (line 21) | type Query interface
type Database (line 26) | type Database interface
type Update (line 37) | type Update struct
method GetValues (line 44) | func (u *Update) GetValues() map[string]interface{} {
method SetValues (line 48) | func (u *Update) SetValues(v map[string]interface{}) {
function GetDatabaseForFile (line 53) | func GetDatabaseForFile(database string) *sql.DB {
function ProcessSqlQueryForDatabaseType (line 67) | func ProcessSqlQueryForDatabaseType(q Query, rowData map[string]interfac...
FILE: database/sqlite.go
type SQLite (line 10) | type SQLite struct
method Update (line 15) | func (db *SQLite) Update(q *Update) {
method GetFileName (line 49) | func (db *SQLite) GetFileName() string {
method GetDatabaseReference (line 53) | func (db *SQLite) GetDatabaseReference() *sql.DB {
method CloseDatabaseReference (line 57) | func (db *SQLite) CloseDatabaseReference() {
method SetDatabaseReference (line 62) | func (db *SQLite) SetDatabaseReference(dbPath string) {
method GetPlaceholderForDatabaseType (line 68) | func (db SQLite) GetPlaceholderForDatabaseType() string {
method GetTableNamesQuery (line 72) | func (db SQLite) GetTableNamesQuery() string {
method GenerateQuery (line 80) | func (db *SQLite) GenerateQuery(u *Update) (string, []string) {
FILE: list/defaultitem.go
type DefaultItemStyles (line 15) | type DefaultItemStyles struct
function NewDefaultItemStyles (line 34) | func NewDefaultItemStyles() (s DefaultItemStyles) {
type DefaultItem (line 64) | type DefaultItem interface
type DefaultDelegate (line 83) | type DefaultDelegate struct
method Height (line 102) | func (d DefaultDelegate) Height() int {
method SetSpacing (line 110) | func (d *DefaultDelegate) SetSpacing(i int) {
method Spacing (line 115) | func (d DefaultDelegate) Spacing() int {
method Update (line 120) | func (d DefaultDelegate) Update(msg tea.Msg, m *Model) tea.Cmd {
method Render (line 128) | func (d DefaultDelegate) Render(w io.Writer, m Model, index int, item ...
method ShortHelp (line 192) | func (d DefaultDelegate) ShortHelp() []key.Binding {
method FullHelp (line 200) | func (d DefaultDelegate) FullHelp() [][]key.Binding {
function NewDefaultDelegate (line 93) | func NewDefaultDelegate() DefaultDelegate {
FILE: list/keys.go
type KeyMap (line 7) | type KeyMap struct
function DefaultKeyMap (line 35) | func DefaultKeyMap() KeyMap {
FILE: list/list.go
type Item (line 26) | type Item interface
type ItemDelegate (line 38) | type ItemDelegate interface
type filteredItem (line 55) | type filteredItem struct
type filteredItems (line 60) | type filteredItems
method items (line 62) | func (f filteredItems) items() []Item {
method matches (line 70) | func (f filteredItems) matches() [][]int {
type FilterMatchesMessage (line 78) | type FilterMatchesMessage
type statusMessageTimeoutMsg (line 80) | type statusMessageTimeoutMsg struct
type FilterState (line 83) | type FilterState
method String (line 93) | func (f FilterState) String() string {
constant Unfiltered (line 87) | Unfiltered FilterState = iota
constant Filtering (line 88) | Filtering
constant FilterApplied (line 89) | FilterApplied
type Model (line 102) | type Model struct
method SetFilteringEnabled (line 201) | func (m *Model) SetFilteringEnabled(v bool) {
method FilteringEnabled (line 210) | func (m Model) FilteringEnabled() bool {
method SetShowTitle (line 215) | func (m *Model) SetShowTitle(v bool) {
method ShowTitle (line 221) | func (m Model) ShowTitle() bool {
method SetShowFilter (line 231) | func (m *Model) SetShowFilter(v bool) {
method ShowFilter (line 240) | func (m Model) ShowFilter() bool {
method SetShowStatusBar (line 246) | func (m *Model) SetShowStatusBar(v bool) {
method ShowStatusBar (line 252) | func (m Model) ShowStatusBar() bool {
method SetShowPagination (line 258) | func (m *Model) SetShowPagination(v bool) {
method ShowPagination (line 264) | func (m *Model) ShowPagination() bool {
method SetShowHelp (line 269) | func (m *Model) SetShowHelp(v bool) {
method ShowHelp (line 275) | func (m Model) ShowHelp() bool {
method Items (line 280) | func (m Model) Items() []Item {
method SetItems (line 285) | func (m *Model) SetItems(i []Item) tea.Cmd {
method Select (line 299) | func (m *Model) Select(index int) {
method ResetSelected (line 305) | func (m *Model) ResetSelected() {
method ResetFilter (line 310) | func (m *Model) ResetFilter() {
method SetItem (line 315) | func (m *Model) SetItem(index int, item Item) tea.Cmd {
method InsertItem (line 328) | func (m *Model) InsertItem(index int, item Item) tea.Cmd {
method RemoveItem (line 343) | func (m *Model) RemoveItem(index int) {
method SetDelegate (line 355) | func (m *Model) SetDelegate(d ItemDelegate) {
method VisibleItems (line 361) | func (m Model) VisibleItems() []Item {
method SelectedItem (line 369) | func (m Model) SelectedItem() Item {
method MatchesForItem (line 384) | func (m Model) MatchesForItem(index int) []int {
method Index (line 393) | func (m Model) Index() int {
method Cursor (line 398) | func (m Model) Cursor() int {
method CursorUp (line 404) | func (m *Model) CursorUp() {
method CursorDown (line 425) | func (m *Model) CursorDown() {
method PrevPage (line 454) | func (m Model) PrevPage() {
method NextPage (line 459) | func (m Model) NextPage() {
method FilterState (line 464) | func (m Model) FilterState() FilterState {
method FilterValue (line 469) | func (m Model) FilterValue() string {
method SettingFilter (line 480) | func (m Model) SettingFilter() bool {
method Width (line 485) | func (m Model) Width() int {
method Height (line 490) | func (m Model) Height() int {
method SetSpinner (line 495) | func (m *Model) SetSpinner(spinner spinner.Spinner) {
method ToggleSpinner (line 500) | func (m *Model) ToggleSpinner() tea.Cmd {
method StartSpinner (line 509) | func (m *Model) StartSpinner() tea.Cmd {
method StopSpinner (line 515) | func (m *Model) StopSpinner() {
method DisableQuitKeybindings (line 521) | func (m *Model) DisableQuitKeybindings() {
method NewStatusMessage (line 528) | func (m *Model) NewStatusMessage(s string) tea.Cmd {
method SetSize (line 544) | func (m *Model) SetSize(width, height int) {
method SetWidth (line 549) | func (m *Model) SetWidth(v int) {
method SetHeight (line 554) | func (m *Model) SetHeight(v int) {
method setSize (line 558) | func (m *Model) setSize(width, height int) {
method resetFiltering (line 568) | func (m *Model) resetFiltering() {
method itemsAsFilterItems (line 580) | func (m Model) itemsAsFilterItems() filteredItems {
method updateKeybindings (line 592) | func (m *Model) updateKeybindings() {
method updatePagination (line 641) | func (m *Model) updatePagination() {
method hideStatusMessage (line 676) | func (m *Model) hideStatusMessage() {
method Update (line 684) | func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
method handleBrowsing (line 722) | func (m *Model) handleBrowsing(msg tea.Msg) tea.Cmd {
method handleFiltering (line 809) | func (m *Model) handleFiltering(msg tea.Msg) tea.Cmd {
method ShortHelp (line 865) | func (m Model) ShortHelp() []key.Binding {
method FullHelp (line 901) | func (m Model) FullHelp() [][]key.Binding {
method View (line 942) | func (m Model) View() string {
method titleView (line 986) | func (m Model) titleView() string {
method statusView (line 1031) | func (m Model) statusView() string {
method paginationView (line 1074) | func (m Model) paginationView() string {
method populatedView (line 1096) | func (m Model) populatedView() string {
method helpView (line 1137) | func (m Model) helpView() string {
method spinnerView (line 1141) | func (m Model) spinnerView() string {
function NewModel (line 153) | func NewModel(items []Item, delegate ItemDelegate, width, height int) Mo...
function filterItems (line 1145) | func filterItems(m Model) tea.Cmd {
function insertItemIntoSlice (line 1173) | func insertItemIntoSlice(items []Item, item Item, index int) []Item {
function removeItemFromSlice (line 1190) | func removeItemFromSlice(i []Item, index int) []Item {
function removeFilterMatchFromSlice (line 1199) | func removeFilterMatchFromSlice(i []filteredItem, index int) []filteredI...
function countEnabledBindings (line 1208) | func countEnabledBindings(groups [][]key.Binding) (agg int) {
function max (line 1219) | func max(a, b int) int {
FILE: list/style.go
constant bullet (line 9) | bullet = "•"
constant ellipsis (line 10) | ellipsis = "…"
type Styles (line 15) | type Styles struct
function DefaultStyles (line 45) | func DefaultStyles() (s Styles) {
FILE: main.go
type DatabaseType (line 23) | type DatabaseType
constant debugPath (line 26) | debugPath = ""
constant DatabaseSQLite (line 30) | DatabaseSQLite DatabaseType = "sqlite"
constant DatabaseMySQL (line 31) | DatabaseMySQL DatabaseType = "mysql"
function main (line 43) | func main() {
function handleFlags (line 162) | func handleFlags() {
FILE: tuiutil/csv2sql.go
function SQLFileName (line 35) | func SQLFileName(csvFileName string) string {
function Convert (line 48) | func Convert(csvFileName, tableName string, keepOrigCols bool) string {
function cleanHeader (line 176) | func cleanHeader(headField string) string {
FILE: tuiutil/textinput.go
constant DefaultBlinkSpeed (line 16) | DefaultBlinkSpeed = time.Millisecond * 530
function nextID (line 27) | func nextID() int {
type initialBlinkMsg (line 35) | type initialBlinkMsg struct
type blinkMsg (line 39) | type blinkMsg struct
type blinkCanceled (line 45) | type blinkCanceled struct
type pasteMsg (line 48) | type pasteMsg
type pasteErrMsg (line 49) | type pasteErrMsg struct
type EchoMode (line 52) | type EchoMode
constant EchoNormal (line 56) | EchoNormal EchoMode = iota
constant EchoPassword (line 60) | EchoPassword
constant EchoNone (line 64) | EchoNone
type blinkCtx (line 70) | type blinkCtx struct
type CursorMode (line 76) | type CursorMode
method String (line 87) | func (c CursorMode) String() string {
constant CursorBlink (line 80) | CursorBlink CursorMode = iota
constant CursorStatic (line 81) | CursorStatic
constant CursorHide (line 82) | CursorHide
type TextInputModel (line 96) | type TextInputModel struct
method SetValue (line 185) | func (m *TextInputModel) SetValue(s string) {
method Value (line 199) | func (m TextInputModel) Value() string {
method Cursor (line 204) | func (m TextInputModel) Cursor() int {
method SetCursor (line 210) | func (m *TextInputModel) SetCursor(pos int) {
method setCursor (line 217) | func (m *TextInputModel) setCursor(pos int) bool {
method CursorStart (line 229) | func (m *TextInputModel) CursorStart() {
method cursorStart (line 235) | func (m *TextInputModel) cursorStart() bool {
method CursorEnd (line 240) | func (m *TextInputModel) CursorEnd() {
method CursorMode (line 246) | func (m TextInputModel) CursorMode() CursorMode {
method SetCursorMode (line 253) | func (m *TextInputModel) SetCursorMode(mode CursorMode) tea.Cmd {
method cursorEnd (line 264) | func (m *TextInputModel) cursorEnd() bool {
method Focused (line 269) | func (m TextInputModel) Focused() bool {
method FocusCommand (line 275) | func (m *TextInputModel) FocusCommand() tea.Cmd {
method Blur (line 287) | func (m *TextInputModel) Blur() {
method Reset (line 294) | func (m *TextInputModel) Reset() bool {
method handlePaste (line 301) | func (m *TextInputModel) handlePaste(v string) bool {
method handleOverflow (line 347) | func (m *TextInputModel) handleOverflow() {
method deleteBeforeCursor (line 392) | func (m *TextInputModel) deleteBeforeCursor() bool {
method deleteAfterCursor (line 401) | func (m *TextInputModel) deleteAfterCursor() bool {
method deleteWordLeft (line 408) | func (m *TextInputModel) deleteWordLeft() bool {
method deleteWordRight (line 448) | func (m *TextInputModel) deleteWordRight() bool {
method wordLeft (line 484) | func (m *TextInputModel) wordLeft() bool {
method wordRight (line 519) | func (m *TextInputModel) wordRight() bool {
method echoTransform (line 551) | func (m TextInputModel) echoTransform(v string) string {
method Update (line 564) | func (m TextInputModel) Update(msg tea.Msg) (TextInputModel, tea.Cmd) {
method View (line 692) | func (m TextInputModel) View() string {
method placeholderView (line 729) | func (m TextInputModel) placeholderView() string {
method cursorView (line 750) | func (m TextInputModel) cursorView(v string) string {
method blinkCmd (line 763) | func (m *TextInputModel) blinkCmd() tea.Cmd {
function NewModel (line 157) | func NewModel() TextInputModel {
function Blink (line 788) | func Blink() tea.Msg {
function Paste (line 793) | func Paste() tea.Msg {
function Clamp (line 801) | func Clamp(v, low, high int) int {
function min (line 805) | func min(a, b int) int {
function max (line 812) | func max(a, b int) int {
FILE: tuiutil/theme.go
constant HighlightKey (line 4) | HighlightKey = "Highlight"
constant HeaderBackgroundKey (line 5) | HeaderBackgroundKey = "HeaderBackground"
constant HeaderBorderBackgroundKey (line 6) | HeaderBorderBackgroundKey = "HeaderBorderBackground"
constant HeaderForegroundKey (line 7) | HeaderForegroundKey = "HeaderForeground"
constant FooterForegroundColorKey (line 8) | FooterForegroundColorKey = "FooterForeground"
constant HeaderBottomColorKey (line 9) | HeaderBottomColorKey = "HeaderBottom"
constant HeaderTopForegroundColorKey (line 10) | HeaderTopForegroundColorKey = "HeaderTopForeground"
constant BorderColorKey (line 11) | BorderColorKey = "BorderColor"
constant TextColorKey (line 12) | TextColorKey = "TextColor"
FILE: tuiutil/wordwrap.go
function Indent (line 16) | func Indent(input string, prefix string, prefixAll bool) string {
FILE: viewer/defs.go
type SQLSnippet (line 11) | type SQLSnippet struct
type ScrollData (line 16) | type ScrollData struct
type TableState (line 23) | type TableState struct
type UIState (line 28) | type UIState struct
type UIData (line 40) | type UIData struct
type FormatState (line 48) | type FormatState struct
type TuiModel (line 57) | type TuiModel struct
FILE: viewer/events.go
function HandleMouseEvents (line 17) | func HandleMouseEvents(m *TuiModel, msg *tea.MouseMsg) {
function HandleWindowSizeEvents (line 43) | func HandleWindowSizeEvents(m *TuiModel, msg *tea.WindowSizeMsg) tea.Cmd {
function HandleClipboardEvents (line 82) | func HandleClipboardEvents(m *TuiModel, str string, command *tea.Cmd, ms...
function HandleKeyboardEvents (line 113) | func HandleKeyboardEvents(m *TuiModel, msg *tea.KeyMsg) tea.Cmd {
FILE: viewer/global.go
type Command (line 13) | type Command
function init (line 19) | func init() {
FILE: viewer/help.go
function GetHelpText (line 3) | func GetHelpText() (help string) {
FILE: viewer/lineedit.go
constant QueryResultsTableName (line 18) | QueryResultsTableName = "results"
type EnterFunction (line 21) | type EnterFunction
type LineEdit (line 23) | type LineEdit struct
function ExitToDefaultView (line 28) | func ExitToDefaultView(m *TuiModel) {
function CreateEmptyBuffer (line 45) | func CreateEmptyBuffer(m *TuiModel, original *interface{}) {
function CreatePopulatedBuffer (line 55) | func CreatePopulatedBuffer(m *TuiModel, original *interface{}, str strin...
function EditEnter (line 65) | func EditEnter(m *TuiModel) {
function handleSQLMode (line 232) | func handleSQLMode(m *TuiModel, input string) {
function populateUndo (line 298) | func populateUndo(m *TuiModel) (old string, new string) {
FILE: viewer/mode.go
function PrepareFormatMode (line 24) | func PrepareFormatMode(m *TuiModel) {
function MoveCursorWithinBounds (line 34) | func MoveCursorWithinBounds(m *TuiModel) {
function HandleEditInput (line 49) | func HandleEditInput(m *TuiModel, str, val string) (ret bool) {
function HandleEditMovement (line 77) | func HandleEditMovement(m *TuiModel, str, val string) (ret bool) {
function HandleFormatMovement (line 118) | func HandleFormatMovement(m *TuiModel, str string) (ret bool) {
function InsertCharacter (line 220) | func InsertCharacter(m *TuiModel, newlineOrTab string) {
function HandleFormatInput (line 256) | func HandleFormatInput(m *TuiModel, str string) bool {
function HandleFormatMode (line 305) | func HandleFormatMode(m *TuiModel, str string) {
function HandleEditMode (line 388) | func HandleEditMode(m *TuiModel, str string) {
FILE: viewer/modelutil.go
method WriteMessage (line 16) | func (m *TuiModel) WriteMessage(s string) {
method CopyMap (line 25) | func (m *TuiModel) CopyMap() (to map[string]interface{}) {
function GetNewModel (line 57) | func GetNewModel(baseFileName string, db *sql.DB) TuiModel {
method SetModel (line 122) | func (m *TuiModel) SetModel(c *sql.Rows, db *sql.DB) error {
method PopulateDataForResult (line 165) | func (m *TuiModel) PopulateDataForResult(c *sql.Rows, indexMap *int, sch...
method SwapTableValues (line 200) | func (m *TuiModel) SwapTableValues(f, t *TableState) {
FILE: viewer/serialize.go
function Serialize (line 19) | func Serialize(m *TuiModel) (string, error) {
function SerializeOverwrite (line 28) | func SerializeOverwrite(m *TuiModel) error {
function SerializeSQLiteDB (line 41) | func SerializeSQLiteDB(db *database.SQLite, m *TuiModel) string {
function SerializeOverwriteSQLiteDB (line 57) | func SerializeOverwriteSQLiteDB(db *database.SQLite, m *TuiModel) {
FILE: viewer/snippets.go
method Title (line 18) | func (s SQLSnippet) Title() string {
method Description (line 22) | func (s SQLSnippet) Description() string {
method FilterValue (line 26) | func (s SQLSnippet) FilterValue() string {
type itemDelegate (line 30) | type itemDelegate struct
method Height (line 32) | func (d itemDelegate) Height() int { return 1 }
method Spacing (line 33) | func (d itemDelegate) Spacing() int { return 0 }
method Update (line 34) | func (d itemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd {
method Render (line 38) | func (d itemDelegate) Render(w io.Writer, m list.Model, index int, lis...
FILE: viewer/table.go
type TableAssembly (line 14) | type TableAssembly
function init (line 24) | func init() {
FILE: viewer/tableutil.go
function AssembleTable (line 13) | func AssembleTable(m *TuiModel) string {
method NumHeaders (line 28) | func (m *TuiModel) NumHeaders() int {
method CellWidth (line 53) | func (m *TuiModel) CellWidth() int {
method GetBaseStyle (line 59) | func (m *TuiModel) GetBaseStyle() lipgloss.Style {
method GetColumn (line 76) | func (m *TuiModel) GetColumn() int {
method GetRow (line 86) | func (m *TuiModel) GetRow() int {
method GetSchemaName (line 97) | func (m *TuiModel) GetSchemaName() string {
method GetHeaders (line 102) | func (m *TuiModel) GetHeaders() []string {
method SetViewSlices (line 108) | func (m *TuiModel) SetViewSlices() {
method GetSchemaData (line 160) | func (m *TuiModel) GetSchemaData() map[string][]interface{} {
method GetSelectedColumnName (line 170) | func (m *TuiModel) GetSelectedColumnName() string {
method GetColumnData (line 180) | func (m *TuiModel) GetColumnData() []interface{} {
method GetRowData (line 188) | func (m *TuiModel) GetRowData() map[string]interface{} {
method GetSelectedOption (line 204) | func (m *TuiModel) GetSelectedOption() (*interface{}, int, []interface{}) {
method DisplayMessage (line 217) | func (m *TuiModel) DisplayMessage(msg string) {
method GetSelectedLineEdit (line 223) | func (m *TuiModel) GetSelectedLineEdit() *LineEdit {
function ToggleColumn (line 231) | func ToggleColumn(m *TuiModel) {
FILE: viewer/ui.go
function GetOffsetForLineNumber (line 22) | func GetOffsetForLineNumber(a int) int {
function SelectOption (line 26) | func SelectOption(m *TuiModel) {
function ScrollDown (line 54) | func ScrollDown(m *TuiModel) {
function ScrollUp (line 74) | func ScrollUp(m *TuiModel) {
function DisplayTable (line 96) | func DisplayTable(m *TuiModel) string {
function GetFormattedTextBuffer (line 144) | func GetFormattedTextBuffer(m *TuiModel) []string {
function DisplayFormatText (line 185) | func DisplayFormatText(m *TuiModel) string {
function ShowClipboard (line 223) | func ShowClipboard(m *TuiModel) string {
function DisplaySelection (line 228) | func DisplaySelection(m *TuiModel) string {
FILE: viewer/util.go
constant HiddenTmpDirectoryName (line 22) | HiddenTmpDirectoryName = ".termdbms"
constant SQLSnippetsFile (line 23) | SQLSnippetsFile = "snippets.termdbms"
function TruncateIfApplicable (line 26) | func TruncateIfApplicable(m *TuiModel, conv string) (s string) {
function GetInterfaceFromString (line 53) | func GetInterfaceFromString(str string, original *interface{}) interface...
function GetStringRepresentationOfInterface (line 80) | func GetStringRepresentationOfInterface(val interface{}) string {
function WriteCSV (line 101) | func WriteCSV(m *TuiModel) { // basically display table but without any ...
function WriteTextFile (line 144) | func WriteTextFile(m *TuiModel, text string) (string, error) {
function IsUrl (line 152) | func IsUrl(fp string) bool {
function FileExists (line 168) | func FileExists(name string) (bool, error) {
function SplitLines (line 179) | func SplitLines(s string) []string {
function GetScrollDownMaximumForSelection (line 194) | func GetScrollDownMaximumForSelection(m *TuiModel) int {
function FormatJson (line 210) | func FormatJson(str string) (string, error) {
function Exists (line 222) | func Exists(path string) (bool, error) {
function Hash (line 233) | func Hash(s string) uint32 {
function CopyFile (line 239) | func CopyFile(src string) (string, int64, error) {
function Min (line 276) | func Min(a, b int) int {
function Max (line 284) | func Max(a, b int) int {
function Abs (line 292) | func Abs(a int) int {
FILE: viewer/viewer.go
method Data (line 22) | func (m *TuiModel) Data() *UIData {
method Table (line 30) | func (m *TuiModel) Table() *TableState {
function SetStyles (line 38) | func SetStyles() {
method Init (line 60) | func (m TuiModel) Init() tea.Cmd {
method Update (line 67) | func (m TuiModel) Update(message tea.Msg) (tea.Model, tea.Cmd) {
method View (line 139) | func (m TuiModel) View() string {
Condensed preview — 32 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (189K chars).
[
{
"path": ".github/workflows/codeql-analysis.yml",
"chars": 2433,
"preview": "# For most projects, this workflow file will not need changing; you simply need\n# to commit it to your repository.\n#\n# Y"
},
{
"path": ".gitignore",
"chars": 29,
"preview": "*.sh\n.idea/*\n.termdbms\nbuild/"
},
{
"path": "CHANGELOG.txt",
"chars": 565,
"preview": "## [Unreleased]\n - MYSQL Support\n - Database creation tools\n\n##[1.0-alpha]\n### Added\n - Added ability to remove snippets"
},
{
"path": "LICENSE",
"chars": 1068,
"preview": "MIT License\n\nCopyright (c) 2021 Matt Farstad\n\nPermission is hereby granted, free of charge, to any person obtaining a co"
},
{
"path": "README.md",
"chars": 5814,
"preview": "# termdbms\n\n## A TUI for viewing and editing databases, written in pure Go\n\n#### Installation Instructions\n\n###### Go In"
},
{
"path": "database/query.go",
"chars": 1653,
"preview": "package database\n\nimport (\n\t\"database/sql\"\n\t\"sync\"\n)\n\nvar (\n\tDBMutex sync.Mutex\n\tDatabases map[string]*sql.DB\n\tD"
},
{
"path": "database/sqlite.go",
"chars": 2172,
"preview": "package database\n\nimport (\n\t\"database/sql\"\n\t\"fmt\"\n\t\"log\"\n\t\"strings\"\n)\n\ntype SQLite struct {\n\tFileName string\n\tDatabase *"
},
{
"path": "go.mod",
"chars": 464,
"preview": "module github.com/mathaou/termdbms\n\ngo 1.16\n\nrequire (\n\tgithub.com/atotto/clipboard v0.1.2\n\tgithub.com/charmbracelet/bub"
},
{
"path": "go.sum",
"chars": 11730,
"preview": "github.com/atotto/clipboard v0.1.2 h1:YZCtFu5Ie8qX2VmVTBnrqLSiU9XOWwqNRmdT3gIQzbY=\ngithub.com/atotto/clipboard v0.1.2/go"
},
{
"path": "list/defaultitem.go",
"chars": 5805,
"preview": "package list\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\n\t\"github.com/charmbracelet/bubbles/key\"\n\ttea \"github.com/charmbracelet/bubbletea\"\n\t"
},
{
"path": "list/keys.go",
"chars": 2597,
"preview": "package list\n\nimport \"github.com/charmbracelet/bubbles/key\"\n\n// KeyMap defines keybindings. It satisfies to the help.Key"
},
{
"path": "list/list.go",
"chars": 30699,
"preview": "// Package list provides a feature-rich Bubble Tea component for browsing\n// a general purpose list of items. It feature"
},
{
"path": "list/style.go",
"chars": 2910,
"preview": "package list\n\nimport (\n\t\"github.com/charmbracelet/lipgloss\"\n\t\"github.com/mathaou/termdbms/tuiutil\"\n)\n\nconst (\n\tbullet "
},
{
"path": "main.go",
"chars": 4175,
"preview": "package main\n\nimport (\n\t\"database/sql\"\n\t\"flag\"\n\t\"fmt\"\n\t\"io/fs\"\n\t\"io/ioutil\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t. \"githu"
},
{
"path": "tuiutil/csv2sql.go",
"chars": 8195,
"preview": "package tuiutil\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"encoding/csv\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n)\n\n/*\n\n csv"
},
{
"path": "tuiutil/textinput.go",
"chars": 19159,
"preview": "package tuiutil\n\nimport (\n\t\"context\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\t\"unicode\"\n\n\t\"github.com/atotto/clipboard\"\n\ttea \"github."
},
{
"path": "tuiutil/theme.go",
"chars": 2730,
"preview": "package tuiutil\n\nconst (\n\tHighlightKey = \"Highlight\"\n\tHeaderBackgroundKey = \"HeaderBackground\"\n\tH"
},
{
"path": "tuiutil/wordwrap.go",
"chars": 722,
"preview": "package tuiutil\n\nimport (\n\t\"strings\"\n)\n\n// Indent a string with the given prefix at the start of either the first, or al"
},
{
"path": "viewer/defs.go",
"chars": 2101,
"preview": "package viewer\n\nimport (\n\t\"github.com/charmbracelet/bubbles/viewport\"\n\ttea \"github.com/charmbracelet/bubbletea\"\n\t\"github"
},
{
"path": "viewer/events.go",
"chars": 3562,
"preview": "package viewer\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"os\"\n\t\"time\"\n\n\t\"github.com/charmbracelet/bubbles/viewport\"\n\ttea \"githu"
},
{
"path": "viewer/global.go",
"chars": 7925,
"preview": "package viewer\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\n\ttea \"github.com/charmbracelet/bubbletea\"\n\t\"github.com/charmbracelet/lipglos"
},
{
"path": "viewer/help.go",
"chars": 3296,
"preview": "package viewer\n\nfunc GetHelpText() (help string) {\n\thelp = `\n##### Help:\n -p / database path (absolute)\n -d / spec"
},
{
"path": "viewer/lineedit.go",
"chars": 8365,
"preview": "package viewer\n\nimport (\n\t\"database/sql\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"math/rand\"\n\t\"os\"\n\t\"strings\"\n\t\"time\"\n\n\t\"gith"
},
{
"path": "viewer/mode.go",
"chars": 9623,
"preview": "package viewer\n\nimport (\n\t\"strings\"\n\n\ttea \"github.com/charmbracelet/bubbletea\"\n)\n\nvar (\n\tInputBlacklist = []string{\n\t\t\"a"
},
{
"path": "viewer/modelutil.go",
"chars": 5638,
"preview": "package viewer\n\nimport (\n\t\"database/sql\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"os\"\n\t\"strings\"\n\n\ttea \"github.com/charmbracelet/bubble"
},
{
"path": "viewer/serialize.go",
"chars": 1554,
"preview": "package viewer\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"log\"\n\t\"math/rand\"\n\t\"os\"\n\t\"path\"\n\t\"strings\"\n\n\t\"github.com/mathaou/termdbms/da"
},
{
"path": "viewer/snippets.go",
"chars": 1684,
"preview": "package viewer\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"strings\"\n\n\ttea \"github.com/charmbracelet/bubbletea\"\n\t\"github.com/charmbracelet/l"
},
{
"path": "viewer/table.go",
"chars": 3720,
"preview": "package viewer\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\t\"time\"\n\n\ttea \"github.com/charmbracelet/bubbletea\"\n\t\"github.com/charmbracelet"
},
{
"path": "viewer/tableutil.go",
"chars": 5435,
"preview": "package viewer\n\nimport (\n\t\"errors\"\n\n\t\"github.com/charmbracelet/lipgloss\"\n\t\"github.com/mathaou/termdbms/tuiutil\"\n)\n\nvar m"
},
{
"path": "viewer/ui.go",
"chars": 6729,
"preview": "package viewer\n\nimport (\n\t\"fmt\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\ttea \"github.com/charmbracelet/bubbletea\"\n\t\"github.com/ch"
},
{
"path": "viewer/util.go",
"chars": 6135,
"preview": "package viewer\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"hash/fnv\"\n\t\"io\"\n\t\"math/rand\"\n\t\"os\"\n\t\"path"
},
{
"path": "viewer/viewer.go",
"chars": 3525,
"preview": "package viewer\n\nimport (\n\t\"fmt\"\n\n\ttea \"github.com/charmbracelet/bubbletea\"\n\t\"github.com/charmbracelet/lipgloss\"\n\t\"github"
}
]
About this extraction
This page contains the full source code of the mathaou/sqlite-tui GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 32 files (168.2 KB), approximately 51.4k tokens, and a symbol index with 292 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.