2022-10-16 18:22:34 +02:00
package lib
import (
"context"
"fmt"
2022-10-16 21:43:16 +02:00
"os"
"strings"
2022-10-16 18:22:34 +02:00
_ "embed" // for embedding config.sh
2022-10-24 00:21:59 +02:00
"github.com/charmbracelet/bubbles/key"
2022-10-16 18:22:34 +02:00
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/table"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/ddworken/hishtory/client/hctx"
2022-10-16 21:43:16 +02:00
"github.com/muesli/termenv"
2022-10-27 06:48:36 +02:00
"golang.org/x/term"
2022-10-16 18:22:34 +02:00
)
2022-10-16 21:43:16 +02:00
const TABLE_HEIGHT = 20
2022-11-04 04:36:36 +01:00
const PADDED_NUM_ENTRIES = TABLE_HEIGHT * 5
2022-10-16 21:43:16 +02:00
var selectedRow string = ""
2022-10-16 18:22:34 +02:00
var baseStyle = lipgloss . NewStyle ( ) .
BorderStyle ( lipgloss . NormalBorder ( ) ) .
BorderForeground ( lipgloss . Color ( "240" ) )
type model struct {
2022-10-22 08:12:56 +02:00
// context
ctx * context . Context
// Model for the loading spinner.
spinner spinner . Model
// Whether data is still loading and the spinner should still be displayed.
isLoading bool
// Whether the TUI is quitting.
quitting bool
// The table used for displaying search results.
table table . Model
// The number of entries in the table.
2022-10-16 21:55:10 +02:00
numEntries int
2022-10-22 08:12:56 +02:00
// Whether the user has hit enter to select an entry and the TUI is thus about to quit.
selected bool
// The search box for the query
queryInput textinput . Model
2022-10-22 08:29:49 +02:00
// The query to run. Reset to nil after it was run.
runQuery * string
2022-10-22 08:12:56 +02:00
// The previous query that was run.
lastQuery string
// Unrecoverable error.
2022-11-27 17:54:34 +01:00
fatalErr error
2022-10-22 08:12:56 +02:00
// An error while searching. Recoverable and displayed as a warning message.
searchErr error
// Whether the device is offline. If so, a warning will be displayed.
isOffline bool
// A banner from the backend to be displayed. Generally an empty string.
banner string
2022-10-16 18:22:34 +02:00
}
type doneDownloadingMsg struct { }
type offlineMsg struct { }
type bannerMsg struct {
banner string
}
2022-10-16 21:55:10 +02:00
func initialModel ( ctx * context . Context , t table . Model , initialQuery string , numEntries int ) model {
2022-10-16 18:22:34 +02:00
s := spinner . New ( )
s . Spinner = spinner . Dot
s . Style = lipgloss . NewStyle ( ) . Foreground ( lipgloss . Color ( "205" ) )
queryInput := textinput . New ( )
queryInput . Placeholder = "ls"
queryInput . Focus ( )
queryInput . CharLimit = 156
queryInput . Width = 50
2022-10-16 21:43:16 +02:00
if initialQuery != "" {
queryInput . SetValue ( initialQuery )
}
2022-10-22 08:29:49 +02:00
return model { ctx : ctx , spinner : s , isLoading : true , table : t , runQuery : & initialQuery , queryInput : queryInput , numEntries : numEntries }
2022-10-16 18:22:34 +02:00
}
func ( m model ) Init ( ) tea . Cmd {
return m . spinner . Tick
}
2022-11-03 02:30:07 +01:00
func runQueryAndUpdateTable ( m model , updateTable bool ) model {
if ( m . runQuery != nil && * m . runQuery != m . lastQuery ) || updateTable {
if m . runQuery == nil {
m . runQuery = & m . lastQuery
}
2022-10-27 07:11:07 +02:00
rows , numEntries , err := getRows ( m . ctx , hctx . GetConf ( m . ctx ) . DisplayedColumns , * m . runQuery , PADDED_NUM_ENTRIES )
2022-11-27 17:54:34 +01:00
m . searchErr = err
2022-10-16 18:22:34 +02:00
if err != nil {
2022-10-16 21:43:16 +02:00
return m
2022-10-16 18:22:34 +02:00
}
2022-10-16 21:55:10 +02:00
m . numEntries = numEntries
2022-11-03 02:30:07 +01:00
if updateTable {
t , err := makeTable ( m . ctx , rows )
if err != nil {
2022-11-27 17:54:34 +01:00
m . fatalErr = err
2022-11-03 02:30:07 +01:00
return m
}
m . table = t
}
2022-10-16 18:22:34 +02:00
m . table . SetRows ( rows )
m . table . SetCursor ( 0 )
2022-10-22 08:29:49 +02:00
m . lastQuery = * m . runQuery
m . runQuery = nil
2022-10-16 18:22:34 +02:00
}
2022-10-16 21:55:10 +02:00
if m . table . Cursor ( ) >= m . numEntries {
// Ensure that we can't scroll past the end of the table
m . table . SetCursor ( m . numEntries - 1 )
}
2022-10-16 21:43:16 +02:00
return m
}
2022-10-16 18:22:34 +02:00
2022-10-16 21:43:16 +02:00
func ( m model ) Update ( msg tea . Msg ) ( tea . Model , tea . Cmd ) {
2022-10-16 18:22:34 +02:00
switch msg := msg . ( type ) {
case tea . KeyMsg :
switch msg . String ( ) {
2022-10-22 07:58:51 +02:00
case "esc" , "ctrl+c" :
2022-10-16 18:22:34 +02:00
m . quitting = true
return m , tea . Quit
case "enter" :
2022-10-16 21:55:10 +02:00
if m . numEntries != 0 {
m . selected = true
}
2022-10-16 18:22:34 +02:00
return m , tea . Quit
default :
t , cmd1 := m . table . Update ( msg )
m . table = t
2022-10-24 00:21:59 +02:00
if strings . HasPrefix ( msg . String ( ) , "alt+" ) {
return m , tea . Batch ( cmd1 )
}
2022-10-16 18:22:34 +02:00
i , cmd2 := m . queryInput . Update ( msg )
m . queryInput = i
2022-10-22 08:29:49 +02:00
searchQuery := m . queryInput . Value ( )
m . runQuery = & searchQuery
2022-11-03 02:30:07 +01:00
m = runQueryAndUpdateTable ( m , false )
2022-10-16 18:22:34 +02:00
return m , tea . Batch ( cmd1 , cmd2 )
}
2022-11-03 02:30:07 +01:00
case tea . WindowSizeMsg :
m = runQueryAndUpdateTable ( m , true )
return m , nil
2022-10-16 18:22:34 +02:00
case offlineMsg :
m . isOffline = true
return m , nil
case bannerMsg :
m . banner = msg . banner
return m , nil
case doneDownloadingMsg :
m . isLoading = false
return m , nil
default :
var cmd tea . Cmd
if m . isLoading {
m . spinner , cmd = m . spinner . Update ( msg )
return m , cmd
} else {
m . table , cmd = m . table . Update ( msg )
return m , cmd
}
}
}
func ( m model ) View ( ) string {
2022-11-27 17:54:34 +01:00
if m . fatalErr != nil {
return fmt . Sprintf ( "An unrecoverable error occured: %v\n" , m . fatalErr )
2022-10-16 18:22:34 +02:00
}
if m . selected {
2022-10-27 07:11:07 +02:00
indexOfCommand := - 1
for i , columnName := range hctx . GetConf ( m . ctx ) . DisplayedColumns {
if columnName == "Command" {
indexOfCommand = i
break
}
}
if indexOfCommand == - 1 {
selectedRow = "Error: Table doesn't have a column named `Command`?"
return ""
}
selectedRow = m . table . SelectedRow ( ) [ indexOfCommand ]
2022-10-16 21:43:16 +02:00
return ""
}
2022-11-12 01:42:07 +01:00
if m . quitting {
return ""
}
2022-10-16 21:43:16 +02:00
loadingMessage := ""
if m . isLoading {
loadingMessage = fmt . Sprintf ( "%s Loading hishtory entries from other devices..." , m . spinner . View ( ) )
2022-10-16 18:22:34 +02:00
}
2022-10-22 08:07:52 +02:00
warning := ""
2022-10-16 18:22:34 +02:00
if m . isOffline {
2022-10-22 08:07:52 +02:00
warning += "Warning: failed to contact the hishtory backend (are you offline?), so some results may be stale\n\n"
}
if m . searchErr != nil {
warning += fmt . Sprintf ( "Warning: failed to search: %v\n\n" , m . searchErr )
2022-10-16 18:22:34 +02:00
}
2022-10-22 08:07:52 +02:00
return fmt . Sprintf ( "\n%s\n%s%s\nSearch Query: %s\n\n%s\n" , loadingMessage , warning , m . banner , m . queryInput . View ( ) , baseStyle . Render ( m . table . View ( ) ) )
2022-10-16 18:22:34 +02:00
}
2022-10-27 07:11:07 +02:00
func getRows ( ctx * context . Context , columnNames [ ] string , query string , numEntries int ) ( [ ] table . Row , int , error ) {
2022-10-16 18:22:34 +02:00
db := hctx . GetDb ( ctx )
2022-11-04 04:36:36 +01:00
config := hctx . GetConf ( ctx )
2022-11-01 18:23:35 +01:00
data , err := Search ( ctx , db , query , numEntries )
2022-10-16 18:22:34 +02:00
if err != nil {
2022-10-16 21:55:10 +02:00
return nil , 0 , err
2022-10-16 18:22:34 +02:00
}
var rows [ ] table . Row
2022-11-04 04:36:36 +01:00
lastCommand := ""
2022-10-27 06:48:36 +02:00
for i := 0 ; i < numEntries ; i ++ {
2022-10-16 21:43:16 +02:00
if i < len ( data ) {
entry := data [ i ]
2022-11-11 16:54:00 +01:00
if strings . TrimSpace ( entry . Command ) == strings . TrimSpace ( lastCommand ) && config . FilterDuplicateCommands {
2022-11-04 04:36:36 +01:00
continue
}
2022-11-20 07:27:08 +01:00
entry . Command = strings . ReplaceAll ( entry . Command , "\n" , "\\n" )
2022-10-27 07:11:07 +02:00
row , err := buildTableRow ( ctx , columnNames , * entry )
if err != nil {
return nil , 0 , fmt . Errorf ( "failed to build row for entry=%#v: %v" , entry , err )
}
2022-10-16 21:43:16 +02:00
rows = append ( rows , row )
2022-11-04 04:36:36 +01:00
lastCommand = entry . Command
2022-10-16 21:43:16 +02:00
} else {
rows = append ( rows , table . Row { } )
}
2022-10-16 18:22:34 +02:00
}
2022-10-16 21:55:10 +02:00
return rows , len ( data ) , nil
2022-10-16 18:22:34 +02:00
}
2022-11-16 08:20:19 +01:00
func calculateColumnWidths ( rows [ ] table . Row , numColumns int ) [ ] int {
2022-10-27 06:48:36 +02:00
neededColumnWidth := make ( [ ] int , numColumns )
2022-10-27 06:22:26 +02:00
for _ , row := range rows {
for i , v := range row {
2022-10-27 06:48:36 +02:00
neededColumnWidth [ i ] = max ( neededColumnWidth [ i ] , len ( v ) )
2022-10-27 06:22:26 +02:00
}
2022-10-16 18:22:34 +02:00
}
2022-10-27 06:48:36 +02:00
return neededColumnWidth
}
2022-11-03 02:40:31 +01:00
func getTerminalSize ( ) ( int , int , error ) {
return term . GetSize ( 2 )
}
2022-10-27 06:48:36 +02:00
var bigQueryResults [ ] table . Row
func makeTableColumns ( ctx * context . Context , columnNames [ ] string , rows [ ] table . Row ) ( [ ] table . Column , error ) {
2022-11-12 00:57:13 +01:00
// Handle an initial query with no results
if len ( rows ) == 0 || len ( rows [ 0 ] ) == 0 {
allRows , _ , err := getRows ( ctx , columnNames , "" , 25 )
if err != nil {
return nil , err
}
2022-11-16 08:20:19 +01:00
if len ( allRows ) == 0 || len ( allRows [ 0 ] ) == 0 {
// There are truly zero history entries. Let's still display a table in this case rather than erroring out.
allRows = make ( [ ] table . Row , 0 )
row := make ( [ ] string , 0 )
for range columnNames {
row = append ( row , " " )
}
allRows = append ( allRows , row )
}
2022-11-12 00:57:13 +01:00
return makeTableColumns ( ctx , columnNames , allRows )
}
2022-10-27 06:48:36 +02:00
// Calculate the minimum amount of space that we need for each column for the current actual search
2022-11-16 08:20:19 +01:00
columnWidths := calculateColumnWidths ( rows , len ( columnNames ) )
2022-10-27 06:48:36 +02:00
totalWidth := 20
for i , name := range columnNames {
columnWidths [ i ] = max ( columnWidths [ i ] , len ( name ) )
totalWidth += columnWidths [ i ]
}
// Calculate the maximum column width that is useful for each column if we search for the empty string
if bigQueryResults == nil {
2022-10-27 07:11:07 +02:00
bigRows , _ , err := getRows ( ctx , columnNames , "" , 1000 )
2022-10-27 06:48:36 +02:00
if err != nil {
return nil , err
}
bigQueryResults = bigRows
}
2022-11-16 08:20:19 +01:00
maximumColumnWidths := calculateColumnWidths ( bigQueryResults , len ( columnNames ) )
2022-10-27 06:48:36 +02:00
// Get the actual terminal width. If we're below this, opportunistically add some padding aiming for the maximum column widths
2022-11-03 02:40:31 +01:00
terminalWidth , _ , err := getTerminalSize ( )
2022-10-27 06:48:36 +02:00
if err != nil {
return nil , fmt . Errorf ( "failed to get terminal size: %v" , err )
}
2022-10-27 07:11:07 +02:00
for totalWidth < ( terminalWidth - len ( columnNames ) ) {
2022-10-27 06:48:36 +02:00
prevTotalWidth := totalWidth
for i := range columnNames {
if columnWidths [ i ] < maximumColumnWidths [ i ] + 5 {
columnWidths [ i ] += 1
totalWidth += 1
}
}
if totalWidth == prevTotalWidth {
break
}
}
2022-10-27 07:11:07 +02:00
// And if we are too large from the initial query, let's shrink things to make the table fit. We'll use the heuristic of always shrinking the widest column.
for totalWidth > terminalWidth {
largestColumnIdx := - 1
largestColumnSize := - 1
for i := range columnNames {
if columnWidths [ i ] > largestColumnSize {
largestColumnIdx = i
largestColumnSize = columnWidths [ i ]
}
}
columnWidths [ largestColumnIdx ] -= 2
totalWidth -= 2
}
2022-10-27 06:48:36 +02:00
// And finally, create some actual columns!
2022-10-27 06:22:26 +02:00
columns := make ( [ ] table . Column , 0 )
for i , name := range columnNames {
2022-10-27 06:48:36 +02:00
columns = append ( columns , table . Column { Title : name , Width : columnWidths [ i ] } )
2022-10-16 18:22:34 +02:00
}
2022-10-27 06:48:36 +02:00
return columns , nil
2022-10-27 06:22:26 +02:00
}
func max ( a , b int ) int {
if a > b {
return a
}
return b
}
2022-11-03 02:40:31 +01:00
func min ( a , b int ) int {
if a < b {
return a
}
return b
}
2022-10-27 06:22:26 +02:00
2022-10-27 06:48:36 +02:00
func makeTable ( ctx * context . Context , rows [ ] table . Row ) ( table . Model , error ) {
2022-10-27 07:11:07 +02:00
config := hctx . GetConf ( ctx )
columns , err := makeTableColumns ( ctx , config . DisplayedColumns , rows )
2022-10-27 06:48:36 +02:00
if err != nil {
return table . Model { } , err
}
2022-10-24 00:21:59 +02:00
km := table . KeyMap {
LineUp : key . NewBinding (
key . WithKeys ( "up" , "alt+OA" ) ,
key . WithHelp ( "↑" , "scroll up" ) ,
) ,
LineDown : key . NewBinding (
key . WithKeys ( "down" , "alt+OB" ) ,
key . WithHelp ( "↓" , "scroll down" ) ,
) ,
PageUp : key . NewBinding (
key . WithKeys ( "pgup" ) ,
key . WithHelp ( "pgup" , "page up" ) ,
) ,
PageDown : key . NewBinding (
key . WithKeys ( "pgdown" ) ,
key . WithHelp ( "pgdn" , "page down" ) ,
) ,
GotoTop : key . NewBinding (
key . WithKeys ( "home" ) ,
key . WithHelp ( "home" , "go to start" ) ,
) ,
GotoBottom : key . NewBinding (
key . WithKeys ( "end" ) ,
key . WithHelp ( "end" , "go to end" ) ,
) ,
}
2022-11-03 02:40:31 +01:00
_ , terminalHeight , err := getTerminalSize ( )
if err != nil {
return table . Model { } , err
}
2022-11-03 02:50:27 +01:00
tableHeight := min ( TABLE_HEIGHT , terminalHeight - 12 )
2022-10-16 18:22:34 +02:00
t := table . New (
table . WithColumns ( columns ) ,
2022-10-22 08:07:52 +02:00
table . WithRows ( rows ) ,
2022-10-16 18:22:34 +02:00
table . WithFocused ( true ) ,
2022-11-03 02:40:31 +01:00
table . WithHeight ( tableHeight ) ,
2022-10-24 00:21:59 +02:00
table . WithKeyMap ( km ) ,
2022-10-16 18:22:34 +02:00
)
s := table . DefaultStyles ( )
s . Header = s . Header .
BorderStyle ( lipgloss . NormalBorder ( ) ) .
BorderForeground ( lipgloss . Color ( "240" ) ) .
BorderBottom ( true ) .
Bold ( false )
s . Selected = s . Selected .
Foreground ( lipgloss . Color ( "229" ) ) .
Background ( lipgloss . Color ( "57" ) ) .
Bold ( false )
t . SetStyles ( s )
t . Focus ( )
2022-10-27 06:22:26 +02:00
return t , nil
}
2022-10-16 18:22:34 +02:00
2022-11-15 04:26:56 +01:00
func TuiQuery ( ctx * context . Context , initialQuery string ) error {
2022-10-27 06:22:26 +02:00
lipgloss . SetColorProfile ( termenv . ANSI )
2022-10-27 07:11:07 +02:00
rows , numEntries , err := getRows ( ctx , hctx . GetConf ( ctx ) . DisplayedColumns , initialQuery , PADDED_NUM_ENTRIES )
2022-10-27 06:22:26 +02:00
if err != nil {
2022-11-27 17:54:34 +01:00
if initialQuery != "" {
// initialQuery is likely invalid in some way, let's just drop it
return TuiQuery ( ctx , "" )
}
// Something else has gone wrong, crash
2022-10-27 06:22:26 +02:00
return err
}
2022-10-27 06:48:36 +02:00
t , err := makeTable ( ctx , rows )
2022-10-27 06:22:26 +02:00
if err != nil {
return err
}
2022-10-16 21:55:10 +02:00
p := tea . NewProgram ( initialModel ( ctx , t , initialQuery , numEntries ) , tea . WithOutput ( os . Stderr ) )
2022-10-16 18:22:34 +02:00
go func ( ) {
err := RetrieveAdditionalEntriesFromRemote ( ctx )
if err != nil {
p . Send ( err )
}
p . Send ( doneDownloadingMsg { } )
} ( )
2022-11-12 01:42:07 +01:00
// Async: Process deletion requests
2022-11-10 00:07:00 +01:00
go func ( ) {
err := ProcessDeletionRequests ( ctx )
if err != nil {
p . Send ( err )
}
} ( )
2022-11-12 01:42:07 +01:00
// Async: Check for any banner from the server
2022-10-16 18:22:34 +02:00
go func ( ) {
2022-11-15 04:26:56 +01:00
banner , err := GetBanner ( ctx )
2022-10-16 18:22:34 +02:00
if err != nil {
if IsOfflineError ( err ) {
p . Send ( offlineMsg { } )
} else {
p . Send ( err )
}
}
p . Send ( bannerMsg { banner : string ( banner ) } )
} ( )
2022-11-12 01:42:07 +01:00
// Blocking: Start the TUI
2022-11-27 20:59:06 +01:00
_ , err = p . Run ( )
2022-10-16 18:22:34 +02:00
if err != nil {
return err
}
2022-11-12 01:42:07 +01:00
if selectedRow == "" && os . Getenv ( "HISHTORY_TERM_INTEGRATION" ) != "" {
// Print out the initialQuery instead so that we don't clear the terminal
selectedRow = initialQuery
}
2022-11-20 07:27:08 +01:00
fmt . Printf ( "%s\n" , strings . ReplaceAll ( selectedRow , "\\n" , "\n" ) )
2022-10-16 18:22:34 +02:00
return nil
}