2023-09-14 07:47:48 +02:00
package tui
2022-10-16 18:22:34 +02:00
import (
"context"
"fmt"
2022-10-16 21:43:16 +02:00
"os"
2023-11-07 06:39:01 +01:00
"path/filepath"
2023-09-30 03:21:23 +02:00
"regexp"
2022-10-16 21:43:16 +02:00
"strings"
2022-12-19 07:02:29 +01:00
"time"
2022-10-16 18:22:34 +02:00
_ "embed" // for embedding config.sh
2023-02-20 19:54:16 +01:00
"github.com/charmbracelet/bubbles/help"
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/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
2023-11-12 05:41:59 +01:00
"github.com/ddworken/hishtory/client/ai"
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"
2023-09-14 07:47:48 +02:00
"github.com/ddworken/hishtory/client/lib"
2023-02-19 07:26:18 +01:00
"github.com/ddworken/hishtory/client/table"
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
2023-09-30 03:21:23 +02:00
var CURRENT_QUERY_FOR_HIGHLIGHTING string = ""
2023-05-20 02:14:33 +02:00
var SELECTED_COMMAND string = ""
2022-10-16 21:43:16 +02:00
2023-10-26 05:07:09 +02:00
// Globally shared monotonically increasing IDs used to prevent race conditions in handling async queries.
// If the user types 'l' and then 's', two queries will be dispatched: One for 'l' and one for 'ls'. These
// counters are used to ensure that we don't process the query results for 'ls' and then promptly overwrite
// them with the results for 'l'.
var LAST_DISPATCHED_QUERY_ID = 0
var LAST_PROCESSED_QUERY_ID = - 1
2022-10-16 18:22:34 +02:00
var baseStyle = lipgloss . NewStyle ( ) .
BorderStyle ( lipgloss . NormalBorder ( ) ) .
BorderForeground ( lipgloss . Color ( "240" ) )
2023-02-20 19:54:16 +01:00
type keyMap struct {
2023-05-20 02:14:33 +02:00
Up key . Binding
Down key . Binding
PageUp key . Binding
PageDown key . Binding
SelectEntry key . Binding
SelectEntryAndChangeDir key . Binding
Left key . Binding
Right key . Binding
TableLeft key . Binding
TableRight key . Binding
DeleteEntry key . Binding
Help key . Binding
Quit key . Binding
2023-02-20 19:54:16 +01:00
}
var fakeTitleKeyBinding key . Binding = key . NewBinding (
key . WithKeys ( "" ) ,
key . WithHelp ( "hiSHtory: Search your shell history" , "" ) ,
)
var fakeEmptyKeyBinding key . Binding = key . NewBinding (
key . WithKeys ( "" ) ,
key . WithHelp ( "" , "" ) ,
)
func ( k keyMap ) ShortHelp ( ) [ ] key . Binding {
return [ ] key . Binding { fakeTitleKeyBinding , k . Help }
}
func ( k keyMap ) FullHelp ( ) [ ] [ ] key . Binding {
return [ ] [ ] key . Binding {
2023-05-20 02:14:33 +02:00
{ fakeTitleKeyBinding , k . Up , k . Left , k . SelectEntry , k . SelectEntryAndChangeDir } ,
2023-02-20 19:54:16 +01:00
{ fakeEmptyKeyBinding , k . Down , k . Right , k . DeleteEntry } ,
{ fakeEmptyKeyBinding , k . PageUp , k . TableLeft , k . Quit } ,
{ fakeEmptyKeyBinding , k . PageDown , k . TableRight , k . Help } ,
}
}
var keys = keyMap {
Up : key . NewBinding (
key . WithKeys ( "up" , "alt+OA" , "ctrl+p" ) ,
2023-05-20 02:14:33 +02:00
key . WithHelp ( "↑ " , "scroll up " ) ,
2023-02-20 19:54:16 +01:00
) ,
Down : key . NewBinding (
key . WithKeys ( "down" , "alt+OB" , "ctrl+n" ) ,
2023-05-20 02:14:33 +02:00
key . WithHelp ( "↓ " , "scroll down " ) ,
2023-02-20 19:54:16 +01:00
) ,
PageUp : key . NewBinding (
key . WithKeys ( "pgup" ) ,
2023-05-20 02:14:33 +02:00
key . WithHelp ( "pgup" , "page up " ) ,
2023-02-20 19:54:16 +01:00
) ,
PageDown : key . NewBinding (
key . WithKeys ( "pgdown" ) ,
2023-05-20 02:14:33 +02:00
key . WithHelp ( "pgdn" , "page down " ) ,
2023-02-20 19:54:16 +01:00
) ,
SelectEntry : key . NewBinding (
key . WithKeys ( "enter" ) ,
2023-05-20 02:14:33 +02:00
key . WithHelp ( "enter" , "select an entry " ) ,
) ,
SelectEntryAndChangeDir : key . NewBinding (
key . WithKeys ( "ctrl+x" ) ,
key . WithHelp ( "ctrl+x" , "select an entry and cd into that directory" ) ,
2023-02-20 19:54:16 +01:00
) ,
Left : key . NewBinding (
key . WithKeys ( "left" ) ,
2023-05-20 02:14:33 +02:00
key . WithHelp ( "← " , "move left " ) ,
2023-02-20 19:54:16 +01:00
) ,
Right : key . NewBinding (
key . WithKeys ( "right" ) ,
2023-05-20 02:14:33 +02:00
key . WithHelp ( "→ " , "move right " ) ,
2023-02-20 19:54:16 +01:00
) ,
TableLeft : key . NewBinding (
key . WithKeys ( "shift+left" ) ,
2023-05-20 02:14:33 +02:00
key . WithHelp ( "shift+← " , "scroll the table left " ) ,
2023-02-20 19:54:16 +01:00
) ,
TableRight : key . NewBinding (
key . WithKeys ( "shift+right" ) ,
2023-05-20 02:14:33 +02:00
key . WithHelp ( "shift+→ " , "scroll the table right " ) ,
2023-02-20 19:54:16 +01:00
) ,
DeleteEntry : key . NewBinding (
key . WithKeys ( "ctrl+k" ) ,
2023-05-20 02:14:33 +02:00
key . WithHelp ( "ctrl+k" , "delete the highlighted entry " ) ,
2023-02-20 19:54:16 +01:00
) ,
Help : key . NewBinding (
key . WithKeys ( "ctrl+h" ) ,
2023-05-20 02:14:33 +02:00
key . WithHelp ( "ctrl+h" , "help " ) ,
2023-02-20 19:54:16 +01:00
) ,
Quit : key . NewBinding (
key . WithKeys ( "esc" , "ctrl+c" , "ctrl+d" ) ,
2023-05-20 02:14:33 +02:00
key . WithHelp ( "esc" , "exit hiSHtory " ) ,
2023-02-20 19:54:16 +01:00
) ,
}
2023-05-20 02:14:33 +02:00
type SelectStatus int64
const (
NotSelected SelectStatus = iota
Selected
SelectedWithChangeDir
)
2022-10-16 18:22:34 +02:00
type model struct {
2022-10-22 08:12:56 +02:00
// context
2023-09-05 21:45:17 +02:00
ctx context . Context
2022-10-22 08:12:56 +02:00
// Model for the loading spinner.
spinner spinner . Model
// Whether data is still loading and the spinner should still be displayed.
isLoading bool
2023-02-20 19:54:16 +01:00
// Model for the help bar at the bottom of the page
help help . Model
2022-10-22 08:12:56 +02:00
// Whether the TUI is quitting.
quitting bool
2023-09-19 04:35:53 +02:00
// The table used for displaying search results. Nil if the initial search query hasn't returned yet.
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.
2023-05-20 02:14:33 +02:00
selected SelectStatus
2022-10-22 08:12:56 +02:00
// 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
}
2023-08-27 20:42:17 +02:00
type asyncQueryFinishedMsg struct {
2023-10-26 05:07:09 +02:00
// The query ID finished running. Used to ensure that we only process this message if it is the latest query to finish.
queryId int
2023-10-25 07:52:52 +02:00
// The table rows and entries
rows [ ] table . Row
entries [ ] * data . HistoryEntry
// An error from searching, if one occurred
searchErr error
// Whether to force a full refresh of the table
forceUpdateTable bool
// Whether to maintain the cursor position
maintainCursor bool
// An updated search query. May be used for initial queries when they're invalid.
2023-09-19 07:21:10 +02:00
overriddenSearchQuery * string
2023-08-27 20:42:17 +02:00
}
2022-10-16 18:22:34 +02:00
2023-09-19 04:35:53 +02:00
func initialModel ( ctx context . Context , 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 ( )
2023-09-15 06:14:16 +02:00
queryInput . CharLimit = 200
width , _ , err := getTerminalSize ( )
if err == nil {
queryInput . Width = width
} else {
hctx . GetLogger ( ) . Infof ( "getTerminalSize() return err=%#v, defaulting queryInput to a width of 50" , err )
queryInput . Width = 50
}
2022-10-16 21:43:16 +02:00
if initialQuery != "" {
queryInput . SetValue ( initialQuery )
}
2023-09-30 03:21:23 +02:00
CURRENT_QUERY_FOR_HIGHLIGHTING = initialQuery
2023-09-19 04:35:53 +02:00
return model { ctx : ctx , spinner : s , isLoading : true , table : nil , tableEntries : [ ] * data . HistoryEntry { } , runQuery : & initialQuery , queryInput : queryInput , help : help . New ( ) }
2022-10-16 18:22:34 +02:00
}
func ( m model ) Init ( ) tea . Cmd {
return m . spinner . Tick
}
2023-08-30 03:59:20 +02:00
func updateTable ( m model , rows [ ] table . Row , entries [ ] * data . HistoryEntry , searchErr error , forceUpdateTable , maintainCursor bool ) model {
2023-08-27 20:42:17 +02:00
if m . runQuery == nil {
m . runQuery = & m . lastQuery
}
m . searchErr = searchErr
if searchErr != nil {
return m
}
m . tableEntries = entries
2023-09-19 04:35:53 +02:00
initialCursor := 0
if m . table != nil {
initialCursor = m . table . Cursor ( )
}
2023-09-29 06:52:00 +02:00
if forceUpdateTable || m . table == nil {
2023-08-27 20:42:17 +02:00
t , err := makeTable ( m . ctx , rows )
2022-10-16 18:22:34 +02:00
if err != nil {
2023-08-27 20:42:17 +02:00
m . fatalErr = err
2022-10-16 21:43:16 +02:00
return m
2022-10-16 18:22:34 +02:00
}
2023-09-19 04:35:53 +02:00
m . table = & t
2022-10-16 18:22:34 +02:00
}
2023-08-27 20:42:17 +02:00
m . table . SetRows ( rows )
2023-08-30 03:59:20 +02:00
if maintainCursor {
m . table . SetCursor ( initialCursor )
} else {
m . table . SetCursor ( 0 )
}
2023-08-27 20:42:17 +02:00
m . lastQuery = * m . runQuery
m . runQuery = nil
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
2023-08-27 20:42:17 +02:00
func preventTableOverscrolling ( m model ) {
2023-09-19 04:35:53 +02:00
if m . table != nil {
if m . table . Cursor ( ) >= len ( m . tableEntries ) {
// Ensure that we can't scroll past the end of the table
m . table . SetCursor ( len ( m . tableEntries ) - 1 )
}
2023-08-27 20:42:17 +02:00
}
}
2023-08-30 03:59:20 +02:00
func runQueryAndUpdateTable ( m model , forceUpdateTable , maintainCursor bool ) tea . Cmd {
2023-08-27 20:42:17 +02:00
if ( m . runQuery != nil && * m . runQuery != m . lastQuery ) || forceUpdateTable || m . searchErr != nil {
query := m . lastQuery
if m . runQuery != nil {
query = * m . runQuery
}
2023-10-26 05:07:09 +02:00
queryId := LAST_DISPATCHED_QUERY_ID
LAST_DISPATCHED_QUERY_ID += 1
2023-08-27 20:42:17 +02:00
return func ( ) tea . Msg {
rows , entries , searchErr := getRows ( m . ctx , hctx . GetConf ( m . ctx ) . DisplayedColumns , query , PADDED_NUM_ENTRIES )
2023-10-26 05:07:09 +02:00
return asyncQueryFinishedMsg { queryId , rows , entries , searchErr , forceUpdateTable , maintainCursor , nil }
2023-08-27 20:42:17 +02:00
}
}
return nil
}
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 :
2023-02-20 19:54:16 +01:00
switch {
case key . Matches ( msg , keys . Quit ) :
2022-10-16 18:22:34 +02:00
m . quitting = true
return m , tea . Quit
2023-02-20 19:54:16 +01:00
case key . Matches ( msg , keys . SelectEntry ) :
2023-09-19 04:35:53 +02:00
if len ( m . tableEntries ) != 0 && m . table != nil {
2023-05-20 02:14:33 +02:00
m . selected = Selected
}
return m , tea . Quit
case key . Matches ( msg , keys . SelectEntryAndChangeDir ) :
2023-09-19 04:35:53 +02:00
if len ( m . tableEntries ) != 0 && m . table != nil {
2023-05-20 02:14:33 +02:00
m . selected = SelectedWithChangeDir
2022-10-16 21:55:10 +02:00
}
2022-10-16 18:22:34 +02:00
return m , tea . Quit
2023-02-20 19:54:16 +01:00
case key . Matches ( msg , keys . DeleteEntry ) :
2023-09-19 04:35:53 +02:00
if m . table == nil {
return m , nil
}
2022-12-19 07:02:29 +01:00
err := deleteHistoryEntry ( m . ctx , * m . tableEntries [ m . table . Cursor ( ) ] )
if err != nil {
m . fatalErr = err
return m , nil
}
2023-08-30 03:59:20 +02:00
cmd := runQueryAndUpdateTable ( m , true , true )
2023-08-27 20:42:17 +02:00
preventTableOverscrolling ( m )
return m , cmd
2023-02-20 19:54:16 +01:00
case key . Matches ( msg , keys . Help ) :
m . help . ShowAll = ! m . help . ShowAll
return m , nil
2022-10-16 18:22:34 +02:00
default :
2023-09-19 04:35:53 +02:00
pendingCommands := tea . Batch ( )
if m . table != nil {
t , cmd1 := m . table . Update ( msg )
m . table = & t
if strings . HasPrefix ( msg . String ( ) , "alt+" ) {
return m , tea . Batch ( cmd1 )
}
pendingCommands = tea . Batch ( pendingCommands , cmd1 )
2022-10-24 00:21:59 +02:00
}
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
2023-09-30 03:21:23 +02:00
CURRENT_QUERY_FOR_HIGHLIGHTING = searchQuery
2023-08-30 03:59:20 +02:00
cmd3 := runQueryAndUpdateTable ( m , false , false )
2023-08-27 20:42:17 +02:00
preventTableOverscrolling ( m )
2023-09-19 04:35:53 +02:00
return m , tea . Batch ( pendingCommands , cmd2 , cmd3 )
2022-10-16 18:22:34 +02:00
}
2022-11-03 02:30:07 +01:00
case tea . WindowSizeMsg :
2023-02-20 19:54:16 +01:00
m . help . Width = msg . Width
2023-09-15 06:14:16 +02:00
m . queryInput . Width = msg . Width
2023-08-30 03:59:20 +02:00
cmd := runQueryAndUpdateTable ( m , true , true )
2023-08-27 20:42:17 +02:00
return m , cmd
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
2023-08-27 20:42:17 +02:00
case asyncQueryFinishedMsg :
2023-10-26 05:07:09 +02:00
if msg . queryId > LAST_PROCESSED_QUERY_ID {
LAST_PROCESSED_QUERY_ID = msg . queryId
2023-10-25 07:52:52 +02:00
m = updateTable ( m , msg . rows , msg . entries , msg . searchErr , msg . forceUpdateTable , msg . maintainCursor )
if msg . overriddenSearchQuery != nil {
m . queryInput . SetValue ( * msg . overriddenSearchQuery )
}
2023-09-19 07:21:10 +02:00
}
2023-08-27 20:42:17 +02:00
return m , nil
2022-10-16 18:22:34 +02:00
default :
var cmd tea . Cmd
if m . isLoading {
m . spinner , cmd = m . spinner . Update ( msg )
return m , cmd
} else {
2023-09-19 04:35:53 +02:00
if m . table != nil {
t , cmd := m . table . Update ( msg )
m . table = & t
return m , cmd
}
return m , nil
2022-10-16 18:22:34 +02:00
}
}
}
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
}
2023-05-20 02:14:33 +02:00
if m . selected == Selected || m . selected == SelectedWithChangeDir {
SELECTED_COMMAND = m . tableEntries [ m . table . Cursor ( ) ] . Command
if m . selected == SelectedWithChangeDir {
changeDir := m . tableEntries [ m . table . Cursor ( ) ] . CurrentWorkingDirectory
2023-11-07 06:39:01 +01:00
if strings . HasPrefix ( changeDir , "~/" ) {
homedir , err := os . UserHomeDir ( )
if err != nil {
hctx . GetLogger ( ) . Infof ( "UserHomeDir() return err=%v, skipping replacing ~/" , err )
} else {
strippedChangeDir , _ := strings . CutPrefix ( changeDir , "~/" )
changeDir = filepath . Join ( homedir , strippedChangeDir )
}
}
2023-11-02 02:02:37 +01:00
SELECTED_COMMAND = "cd \"" + changeDir + "\" && " + SELECTED_COMMAND
2023-05-20 02:14:33 +02:00
}
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 := ""
2023-10-26 05:26:41 +02:00
if loadingMessage != "" {
warning += "\n"
}
2022-10-16 18:22:34 +02:00
if m . isOffline {
2023-08-28 06:47:44 +02:00
warning += "Warning: failed to contact the hishtory backend (are you offline?), so some results may be stale"
2022-10-22 08:07:52 +02:00
}
if m . searchErr != nil {
2023-10-26 05:26:41 +02:00
if strings . TrimSpace ( warning ) != "" {
warning += "\n"
}
2023-08-28 06:47:44 +02:00
warning += fmt . Sprintf ( "Warning: failed to search: %v" , m . searchErr )
2022-10-16 18:22:34 +02:00
}
2023-08-28 06:47:44 +02:00
warning += "\n"
2023-02-20 19:54:16 +01:00
helpView := m . help . View ( keys )
2023-09-19 04:35:53 +02:00
return fmt . Sprintf ( "\n%s%s%s\nSearch Query: %s\n\n%s\n" , loadingMessage , warning , m . banner , m . queryInput . View ( ) , renderNullableTable ( m ) ) + helpView
}
func renderNullableTable ( m model ) string {
if m . table == nil {
return strings . Repeat ( "\n" , TABLE_HEIGHT + 3 )
}
return baseStyle . Render ( m . table . View ( ) )
2022-10-16 18:22:34 +02:00
}
2023-11-12 02:41:24 +01:00
func getRowsFromAiSuggestions ( ctx context . Context , columnNames [ ] string , query string ) ( [ ] table . Row , [ ] * data . HistoryEntry , error ) {
suggestions , err := ai . DebouncedGetAiSuggestions ( ctx , strings . TrimPrefix ( query , "?" ) , 5 )
if err != nil {
return nil , nil , fmt . Errorf ( "failed to get AI query suggestions: %w" , err )
}
var rows [ ] table . Row
var entries [ ] * data . HistoryEntry
for _ , suggestion := range suggestions {
entry := data . HistoryEntry {
LocalUsername : "OpenAI" ,
Hostname : "OpenAI" ,
Command : suggestion ,
CurrentWorkingDirectory : "N/A" ,
HomeDirectory : "N/A" ,
ExitCode : 0 ,
StartTime : time . Unix ( 0 , 0 ) . UTC ( ) ,
EndTime : time . Unix ( 0 , 0 ) . UTC ( ) ,
DeviceId : "OpenAI" ,
EntryId : "OpenAI" ,
}
entries = append ( entries , & entry )
row , err := lib . BuildTableRow ( ctx , columnNames , entry )
if err != nil {
return nil , nil , fmt . Errorf ( "failed to build row for entry=%#v: %w" , entry , err )
}
rows = append ( rows , row )
}
hctx . GetLogger ( ) . Infof ( "getRowsFromAiSuggestions(%#v) ==> %#v" , query , suggestions )
return rows , entries , nil
}
2023-09-05 21:45:17 +02: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 )
2023-11-12 12:09:56 +01:00
if config . AiCompletion && ! config . IsOffline && strings . HasPrefix ( query , "?" ) && len ( query ) > 1 {
2023-11-12 02:41:24 +01:00
return getRowsFromAiSuggestions ( ctx , columnNames , query )
}
2023-09-14 07:47:48 +02:00
searchResults , err := lib . 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
2023-05-17 02:12:52 +02:00
var filteredData [ ] * data . HistoryEntry
2022-11-04 04:36:36 +01:00
lastCommand := ""
2022-10-27 06:48:36 +02:00
for i := 0 ; i < numEntries ; i ++ {
2023-05-17 02:12:52 +02:00
if i < len ( searchResults ) {
entry := searchResults [ 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" )
2023-09-14 07:47:48 +02:00
row , err := lib . BuildTableRow ( ctx , columnNames , * entry )
2022-10-27 07:11:07 +02:00
if err != nil {
2023-09-05 21:08:55 +02:00
return nil , nil , fmt . Errorf ( "failed to build row for entry=%#v: %w" , entry , err )
2022-10-27 07:11:07 +02:00
}
2022-10-16 21:43:16 +02:00
rows = append ( rows , row )
2023-05-17 02:12:52 +02:00
filteredData = append ( filteredData , entry )
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
}
2023-05-17 02:12:52 +02:00
return rows , filteredData , 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
2023-09-05 21:45:17 +02:00
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 {
2023-09-05 21:08:55 +02:00
return nil , fmt . Errorf ( "failed to get terminal size: %w" , err )
2022-10-27 06:48:36 +02:00
}
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
2023-09-05 21:45:17 +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 {
2023-02-20 19:54:16 +01:00
LineUp : keys . Up ,
LineDown : keys . Down ,
PageUp : keys . PageUp ,
PageDown : keys . PageDown ,
2022-10-24 00:21:59 +02:00
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-20 19:54:16 +01:00
MoveLeft : keys . TableLeft ,
MoveRight : keys . TableRight ,
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 )
2023-10-12 03:18:56 +02:00
if config . HighlightMatches {
2023-09-30 03:21:23 +02:00
MATCH_NOTHING_REGEXP := regexp . MustCompile ( "a^" )
s . RenderCell = func ( model table . Model , value string , position table . CellPosition ) string {
var re * regexp . Regexp
CURRENT_QUERY_FOR_HIGHLIGHTING = strings . TrimSpace ( CURRENT_QUERY_FOR_HIGHLIGHTING )
if CURRENT_QUERY_FOR_HIGHLIGHTING == "" {
// If there is no search query, then there is nothing to highlight
re = MATCH_NOTHING_REGEXP
} else {
queryRegex := lib . MakeRegexFromQuery ( CURRENT_QUERY_FOR_HIGHLIGHTING )
r , err := regexp . Compile ( queryRegex )
if err != nil {
// Failed to compile the regex for highlighting matches, this should never happen. In this
// case, just use a regexp that matches nothing to ensure that the TUI doesn't crash.
re = MATCH_NOTHING_REGEXP
} else {
re = r
}
}
// func to render a given chunk of `value`. `isMatching` is whether `v` matches the search query (and
// thus needs to be highlighted). `isLeftMost` and `isRightMost` determines whether additional
// padding is added (to reproduce the padding that `s.Cell` normally adds).
renderChunk := func ( v string , isMatching , isLeftMost , isRightMost bool ) string {
chunkStyle := lipgloss . NewStyle ( )
if position . IsRowSelected {
// Apply the selected style as the base style if this is the highlighted row of the table
chunkStyle = s . Selected . Copy ( )
}
if isLeftMost {
chunkStyle = chunkStyle . PaddingLeft ( 1 )
}
if isRightMost {
chunkStyle = chunkStyle . PaddingRight ( 1 )
}
if isMatching {
chunkStyle = chunkStyle . Bold ( true )
}
return chunkStyle . Render ( v )
}
matches := re . FindAllStringIndex ( value , - 1 )
if len ( matches ) == 0 {
// No matches, so render the entire value
return renderChunk ( value /*isMatching = */ , false /*isLeftMost = */ , true /*isRightMost = */ , true )
}
// Iterate through the chunks of the value and highlight the relevant pieces
ret := ""
lastIncludedIdx := 0
for _ , match := range re . FindAllStringIndex ( value , - 1 ) {
matchStartIdx := match [ 0 ]
matchEndIdx := match [ 1 ]
beforeMatch := value [ lastIncludedIdx : matchStartIdx ]
if beforeMatch != "" {
ret += renderChunk ( beforeMatch , false , lastIncludedIdx == 0 , false )
}
match := value [ matchStartIdx : matchEndIdx ]
ret += renderChunk ( match , true , matchStartIdx == 0 , matchEndIdx == len ( value ) )
lastIncludedIdx = matchEndIdx
}
if lastIncludedIdx != len ( value ) {
ret += renderChunk ( value [ lastIncludedIdx : ] , false , false , true )
}
return ret
}
}
2022-10-16 18:22:34 +02:00
t . SetStyles ( s )
t . Focus ( )
2022-10-27 06:22:26 +02:00
return t , nil
}
2022-10-16 18:22:34 +02:00
2023-09-05 21:45:17 +02:00
func deleteHistoryEntry ( ctx context . Context , entry data . HistoryEntry ) error {
2022-12-19 07:02:29 +01:00
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
}
2023-04-30 17:50:17 +02:00
2022-12-19 07:02:29 +01:00
// Delete remotely
2023-04-30 17:50:17 +02:00
config := hctx . GetConf ( ctx )
if config . IsOffline {
return nil
}
2022-12-19 07:02:29 +01:00
dr := shared . DeletionRequest {
UserId : data . UserId ( hctx . GetConf ( ctx ) . UserSecret ) ,
SendTime : time . Now ( ) ,
}
2023-09-22 22:13:46 +02:00
dr . Messages . Ids = append ( dr . Messages . Ids ,
2023-09-22 23:03:41 +02:00
shared . MessageIdentifier { DeviceId : entry . DeviceId , EndTime : entry . EndTime , EntryId : entry . EntryId } ,
2023-09-22 22:13:46 +02:00
)
2023-10-14 19:52:35 +02:00
return lib . SendDeletionRequest ( ctx , dr )
2022-12-19 07:02:29 +01:00
}
2023-09-05 21:45:17 +02:00
func TuiQuery ( ctx context . Context , initialQuery string ) error {
2022-10-27 06:22:26 +02:00
lipgloss . SetColorProfile ( termenv . ANSI )
2023-09-19 04:35:53 +02:00
p := tea . NewProgram ( initialModel ( ctx , initialQuery ) , tea . WithOutput ( os . Stderr ) )
// Async: Get the initial set of rows
go func ( ) {
2023-10-26 05:07:09 +02:00
queryId := LAST_DISPATCHED_QUERY_ID
LAST_DISPATCHED_QUERY_ID ++
2023-09-19 04:35:53 +02:00
rows , entries , err := getRows ( ctx , hctx . GetConf ( ctx ) . DisplayedColumns , initialQuery , PADDED_NUM_ENTRIES )
2023-09-19 07:21:10 +02:00
if err == nil || initialQuery == "" {
2023-10-26 05:07:09 +02:00
p . Send ( asyncQueryFinishedMsg { queryId : queryId , rows : rows , entries : entries , searchErr : err , forceUpdateTable : true , maintainCursor : false , overriddenSearchQuery : nil } )
2023-09-19 07:21:10 +02:00
} else {
// initialQuery is likely invalid in some way, let's just drop it
emptyQuery := ""
rows , entries , err := getRows ( ctx , hctx . GetConf ( ctx ) . DisplayedColumns , emptyQuery , PADDED_NUM_ENTRIES )
2023-10-26 05:07:09 +02:00
p . Send ( asyncQueryFinishedMsg { queryId : queryId , rows : rows , entries : entries , searchErr : err , forceUpdateTable : true , maintainCursor : false , overriddenSearchQuery : & emptyQuery } )
2023-09-19 07:21:10 +02:00
}
2023-09-19 04:35:53 +02:00
} ( )
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 ( ) {
2023-10-15 21:29:50 +02:00
err := lib . RetrieveAdditionalEntriesFromRemote ( ctx , "tui" )
2022-10-16 18:22:34 +02:00
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 ( ) {
2023-09-14 07:47:48 +02:00
err := lib . ProcessDeletionRequests ( ctx )
2022-11-10 00:07:00 +01:00
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 ( ) {
2023-09-14 07:47:48 +02:00
banner , err := lib . GetBanner ( ctx )
2022-10-16 18:22:34 +02:00
if err != nil {
2023-10-14 19:52:35 +02:00
if lib . IsOfflineError ( ctx , err ) {
2022-10-16 18:22:34 +02:00
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
2023-09-19 04:35:53 +02:00
_ , err := p . Run ( )
2022-10-16 18:22:34 +02:00
if err != nil {
return err
}
2023-05-20 02:14:33 +02:00
if SELECTED_COMMAND == "" && 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
2023-05-20 02:14:33 +02:00
SELECTED_COMMAND = initialQuery
2022-11-12 01:42:07 +01:00
}
2023-05-20 02:14:33 +02:00
fmt . Printf ( "%s\n" , strings . ReplaceAll ( SELECTED_COMMAND , "\\n" , "\n" ) )
2022-10-16 18:22:34 +02:00
return nil
}
2023-05-20 02:16:54 +02:00
// TODO: support custom key bindings
// TODO: make the help page wrap