2022-10-16 18:22:34 +02:00
package lib
import (
"context"
"fmt"
2022-10-16 21:43:16 +02:00
"os"
"strings"
2022-12-19 07:02:29 +01:00
"time"
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"
2023-02-04 14:24:02 +01:00
"github.com/ddworken/hishtory/client/table"
2022-10-16 18:22:34 +02:00
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
2022-12-19 06:26:00 +01:00
"github.com/ddworken/hishtory/client/data"
2022-10-16 18:22:34 +02:00
"github.com/ddworken/hishtory/client/hctx"
2022-12-19 07:02:29 +01:00
"github.com/ddworken/hishtory/shared"
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
2022-12-19 07:29:23 +01:00
var selectedCommand string = ""
2022-10-16 21:43:16 +02:00
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
2022-12-19 06:26:00 +01:00
// The entries in the table
tableEntries [ ] * data . HistoryEntry
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-12-19 06:26:00 +01:00
func initialModel ( ctx * context . Context , t table . Model , tableEntries [ ] * data . HistoryEntry , initialQuery string ) 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-12-19 06:26:00 +01:00
return model { ctx : ctx , spinner : s , isLoading : true , table : t , tableEntries : tableEntries , runQuery : & initialQuery , queryInput : queryInput }
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-12-19 07:02:29 +01:00
rows , entries , 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-12-19 06:26:00 +01:00
m . tableEntries = entries
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-12-19 06:26:00 +01:00
if m . table . Cursor ( ) >= len ( m . tableEntries ) {
2022-10-16 21:55:10 +02:00
// Ensure that we can't scroll past the end of the table
2022-12-19 06:26:00 +01:00
m . table . SetCursor ( len ( m . tableEntries ) - 1 )
2022-10-16 21:55:10 +02:00
}
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-12-18 09:27:29 +01:00
case "esc" , "ctrl+c" , "ctrl+d" :
2022-10-16 18:22:34 +02:00
m . quitting = true
return m , tea . Quit
case "enter" :
2022-12-19 06:26:00 +01:00
if len ( m . tableEntries ) != 0 {
2022-10-16 21:55:10 +02:00
m . selected = true
}
2022-10-16 18:22:34 +02:00
return m , tea . Quit
2022-12-19 07:02:29 +01:00
case "ctrl+k" :
err := deleteHistoryEntry ( m . ctx , * m . tableEntries [ m . table . Cursor ( ) ] )
if err != nil {
m . fatalErr = err
return m , nil
}
m = runQueryAndUpdateTable ( m , true )
return m , nil
2022-10-16 18:22:34 +02:00
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-12-19 07:29:23 +01:00
selectedCommand = m . tableEntries [ m . table . Cursor ( ) ] . Command
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-12-19 07:02:29 +01:00
func getRows ( ctx * context . Context , columnNames [ ] string , query string , numEntries int ) ( [ ] table . Row , [ ] * data . HistoryEntry , 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-12-19 07:02:29 +01:00
return nil , nil , 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 {
2022-12-19 07:02:29 +01:00
return nil , nil , fmt . Errorf ( "failed to build row for entry=%#v: %v" , entry , err )
2022-10-27 07:11:07 +02:00
}
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-12-19 07:02:29 +01:00
return rows , 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 {
2022-12-19 07:02:29 +01:00
allRows , _ , err := getRows ( ctx , columnNames , "" , 25 )
2022-11-12 00:57:13 +01:00
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-12-19 07:02:29 +01: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 (
2022-12-17 06:20:00 +01:00
key . WithKeys ( "up" , "alt+OA" , "ctrl+p" ) ,
2022-10-24 00:21:59 +02:00
key . WithHelp ( "↑" , "scroll up" ) ,
) ,
LineDown : key . NewBinding (
2022-12-17 06:20:00 +01:00
key . WithKeys ( "down" , "alt+OB" , "ctrl+n" ) ,
2022-10-24 00:21:59 +02:00
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" ) ,
) ,
2023-02-04 14:24:02 +01:00
MoveLeft : key . NewBinding (
key . WithKeys ( "shift+left" ) ,
key . WithHelp ( "Shift+←" , "move left" ) ,
) ,
MoveRight : key . NewBinding (
key . WithKeys ( "shift+right" ) ,
key . WithHelp ( "Shift+→" , "move right" ) ,
) ,
2022-10-24 00:21:59 +02:00
}
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-12-19 07:02:29 +01:00
func deleteHistoryEntry ( ctx * context . Context , entry data . HistoryEntry ) error {
db := hctx . GetDb ( ctx )
// Delete locally
r := db . Model ( & data . HistoryEntry { } ) . Where ( "device_id = ? AND end_time = ?" , entry . DeviceId , entry . EndTime ) . Delete ( & data . HistoryEntry { } )
if r . Error != nil {
return r . Error
}
// Delete remotely
dr := shared . DeletionRequest {
UserId : data . UserId ( hctx . GetConf ( ctx ) . UserSecret ) ,
SendTime : time . Now ( ) ,
}
dr . Messages . Ids = append ( dr . Messages . Ids , shared . MessageIdentifier { Date : entry . EndTime , DeviceId : entry . DeviceId } )
return SendDeletionRequest ( dr )
}
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-12-19 07:02:29 +01:00
rows , entries , 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-12-19 06:26:00 +01:00
p := tea . NewProgram ( initialModel ( ctx , t , entries , initialQuery ) , tea . WithOutput ( os . Stderr ) )
2022-12-18 06:55:30 +01:00
// Async: Retrieve additional entries from the backend
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-12-19 07:29:23 +01:00
if selectedCommand == "" && os . Getenv ( "HISHTORY_TERM_INTEGRATION" ) != "" {
2022-11-12 01:42:07 +01:00
// Print out the initialQuery instead so that we don't clear the terminal
2022-12-19 07:29:23 +01:00
selectedCommand = initialQuery
2022-11-12 01:42:07 +01:00
}
2022-12-19 07:29:23 +01:00
fmt . Printf ( "%s\n" , strings . ReplaceAll ( selectedCommand , "\\n" , "\n" ) )
2022-10-16 18:22:34 +02:00
return nil
}