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"
2023-12-31 22:00:56 +01:00
"strconv"
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"
2024-04-28 22:50:09 +02:00
"github.com/ddworken/hishtory/client/tui/keybindings"
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
2023-11-19 15:12:39 +01:00
var LAST_DISPATCHED_QUERY_TIMESTAMP time . Time
2023-10-26 05:07:09 +02:00
var LAST_PROCESSED_QUERY_ID = - 1
2023-05-20 02:14:33 +02:00
type SelectStatus int64
const (
NotSelected SelectStatus = iota
Selected
SelectedWithChangeDir
)
2024-04-28 22:50:09 +02:00
var loadedKeyBindings keybindings . KeyMap = keybindings . DefaultKeyMap
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
2024-02-19 21:12:04 +01:00
// The currently executing shell. Defaults to bash if not specified. Used for more precise AI suggestions.
shellName 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
2024-02-19 21:12:04 +01:00
func initialModel ( ctx context . Context , shellName , 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 ( )
2024-01-08 06:06:22 +01:00
defaultFilter := hctx . GetConf ( ctx ) . DefaultFilter
if defaultFilter != "" {
queryInput . Prompt = "[" + defaultFilter + "] "
}
queryInput . PromptStyle = queryInput . PlaceholderStyle
if defaultFilter == "" {
queryInput . Placeholder = "ls"
}
2022-10-16 18:22:34 +02:00
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
2024-02-19 21:12:04 +01:00
return model { ctx : ctx , spinner : s , isLoading : true , table : nil , tableEntries : [ ] * data . HistoryEntry { } , runQuery : & initialQuery , queryInput : queryInput , help : help . New ( ) , shellName : shellName }
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 {
2024-02-19 21:12:04 +01:00
t , err := makeTable ( m . ctx , m . shellName , 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
2024-04-13 18:53:00 +02:00
preventTableOverscrolling ( m )
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-12-10 00:51:04 +01:00
LAST_DISPATCHED_QUERY_ID ++
2023-10-26 05:07:09 +02:00
queryId := LAST_DISPATCHED_QUERY_ID
2023-11-19 15:12:39 +01:00
LAST_DISPATCHED_QUERY_TIMESTAMP = time . Now ( )
2023-08-27 20:42:17 +02:00
return func ( ) tea . Msg {
2024-01-08 06:06:22 +01:00
conf := hctx . GetConf ( m . ctx )
defaultFilter := conf . DefaultFilter
if m . queryInput . Prompt == "" {
// The default filter was cleared for this session, so don't apply it
defaultFilter = ""
}
2024-02-19 21:12:04 +01:00
rows , entries , searchErr := getRows ( m . ctx , conf . DisplayedColumns , m . shellName , defaultFilter , 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 {
2024-04-28 22:50:09 +02:00
case key . Matches ( msg , loadedKeyBindings . Quit ) :
2022-10-16 18:22:34 +02:00
m . quitting = true
return m , tea . Quit
2024-04-28 22:50:09 +02:00
case key . Matches ( msg , loadedKeyBindings . 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
2024-04-28 22:50:09 +02:00
case key . Matches ( msg , loadedKeyBindings . 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
2024-04-28 22:50:09 +02:00
case key . Matches ( msg , loadedKeyBindings . 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
2024-04-28 22:50:09 +02:00
case key . Matches ( msg , loadedKeyBindings . Help ) :
2023-02-20 19:54:16 +01:00
m . help . ShowAll = ! m . help . ShowAll
return m , nil
2024-04-28 22:50:09 +02:00
case key . Matches ( msg , loadedKeyBindings . JumpStartOfInput ) :
2024-02-19 21:26:44 +01:00
m . queryInput . SetCursor ( 0 )
return m , nil
2024-04-28 22:50:09 +02:00
case key . Matches ( msg , loadedKeyBindings . JumpEndOfInput ) :
2024-02-19 21:26:44 +01:00
m . queryInput . SetCursor ( len ( m . queryInput . Value ( ) ) )
return m , nil
2024-04-28 22:50:09 +02:00
case key . Matches ( msg , loadedKeyBindings . WordLeft ) :
2024-02-19 22:54:42 +01:00
wordBoundaries := calculateWordBoundaries ( m . queryInput . Value ( ) )
lastBoundary := 0
for _ , boundary := range wordBoundaries {
if boundary >= m . queryInput . Position ( ) {
m . queryInput . SetCursor ( lastBoundary )
break
}
lastBoundary = boundary
}
return m , nil
2024-04-28 22:50:09 +02:00
case key . Matches ( msg , loadedKeyBindings . WordRight ) :
2024-02-19 22:54:42 +01:00
wordBoundaries := calculateWordBoundaries ( m . queryInput . Value ( ) )
for _ , boundary := range wordBoundaries {
if boundary > m . queryInput . Position ( ) {
m . queryInput . SetCursor ( boundary )
break
}
}
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
}
2024-01-08 06:06:22 +01:00
forceUpdateTable := false
if msg . String ( ) == "backspace" && ( m . queryInput . Value ( ) == "" || m . queryInput . Position ( ) == 0 ) {
// Handle deleting the default filter just for this TUI instance
m . queryInput . Prompt = ""
forceUpdateTable = true
}
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
2024-01-08 06:06:22 +01:00
cmd3 := runQueryAndUpdateTable ( m , forceUpdateTable , 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
}
}
}
2024-02-19 22:54:42 +01:00
func calculateWordBoundaries ( input string ) [ ] int {
ret := make ( [ ] int , 0 )
ret = append ( ret , 0 )
2024-02-22 07:16:58 +01:00
prevWasBreaking := false
2024-02-19 22:54:42 +01:00
for idx , char := range input {
if char == ' ' || char == '-' {
2024-02-22 07:16:58 +01:00
if ! prevWasBreaking {
ret = append ( ret , idx )
}
prevWasBreaking = true
} else {
prevWasBreaking = false
2024-02-19 22:54:42 +01:00
}
}
2024-02-22 07:16:58 +01:00
if ! prevWasBreaking {
ret = append ( ret , len ( input ) )
}
2024-02-19 22:54:42 +01:00
return ret
}
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 ""
}
2023-11-19 15:12:39 +01:00
additionalMessages := make ( [ ] string , 0 )
2022-10-16 21:43:16 +02:00
if m . isLoading {
2023-11-19 15:12:39 +01:00
additionalMessages = append ( additionalMessages , fmt . Sprintf ( "%s Loading hishtory entries from other devices..." , m . spinner . View ( ) ) )
2023-10-26 05:26:41 +02:00
}
2022-10-16 18:22:34 +02:00
if m . isOffline {
2023-11-19 15:12:39 +01:00
additionalMessages = append ( additionalMessages , "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-11-19 15:12:39 +01:00
additionalMessages = append ( additionalMessages , fmt . Sprintf ( "Warning: failed to search: %v" , m . searchErr ) )
}
if LAST_PROCESSED_QUERY_ID < LAST_DISPATCHED_QUERY_ID && time . Since ( LAST_DISPATCHED_QUERY_TIMESTAMP ) > time . Second {
additionalMessages = append ( additionalMessages , fmt . Sprintf ( "%s Executing search query..." , m . spinner . View ( ) ) )
2022-10-16 18:22:34 +02:00
}
2023-11-19 15:12:39 +01:00
additionalMessagesStr := strings . Join ( additionalMessages , "\n" ) + "\n"
2023-12-07 01:12:41 +01:00
if isExtraCompactHeightMode ( ) {
additionalMessagesStr = "\n"
}
2024-04-28 22:50:09 +02:00
helpView := m . help . View ( loadedKeyBindings )
2023-12-07 01:12:41 +01:00
if isExtraCompactHeightMode ( ) {
helpView = ""
}
2023-12-04 06:56:45 +01:00
additionalSpacing := "\n"
if isCompactHeightMode ( ) {
additionalSpacing = ""
}
2023-12-07 01:12:41 +01:00
return fmt . Sprintf ( "%s%s%s%sSearch Query: %s\n%s%s\n" , additionalSpacing , additionalMessagesStr , m . banner , additionalSpacing , m . queryInput . View ( ) , additionalSpacing , renderNullableTable ( m , helpView ) ) + helpView
}
func isExtraCompactHeightMode ( ) bool {
_ , height , err := getTerminalSize ( )
if err != nil {
hctx . GetLogger ( ) . Infof ( "got err=%v when retrieving terminal dimensions, assuming the terminal is reasonably tall" , err )
return false
}
return height < 15
2023-09-19 04:35:53 +02:00
}
2023-12-04 06:56:45 +01:00
func isCompactHeightMode ( ) bool {
_ , height , err := getTerminalSize ( )
if err != nil {
hctx . GetLogger ( ) . Infof ( "got err=%v when retrieving terminal dimensions, assuming the terminal is reasonably tall" , err )
return false
}
return height < 25
}
2023-12-19 05:32:11 +01:00
func getBaseStyle ( config hctx . ClientConfig ) lipgloss . Style {
return lipgloss . NewStyle ( ) .
BorderStyle ( lipgloss . NormalBorder ( ) ) .
BorderForeground ( lipgloss . Color ( config . ColorScheme . BorderColor ) )
}
2023-12-04 06:56:45 +01:00
func renderNullableTable ( m model , helpText string ) string {
2023-09-19 04:35:53 +02:00
if m . table == nil {
return strings . Repeat ( "\n" , TABLE_HEIGHT + 3 )
}
2023-12-04 06:56:45 +01:00
helpTextLen := strings . Count ( helpText , "\n" )
2023-12-19 05:32:11 +01:00
baseStyle := getBaseStyle ( * hctx . GetConf ( m . ctx ) )
2023-12-04 06:56:45 +01:00
if isCompactHeightMode ( ) && helpTextLen > 1 {
// If the help text is expanded, and this is a small window, then we truncate the table so that the help text displays on top of it
lines := strings . Split ( baseStyle . Render ( m . table . View ( ) ) , "\n" )
truncated := lines [ : len ( lines ) - helpTextLen ]
return strings . Join ( truncated , "\n" )
}
2023-09-19 04:35:53 +02:00
return baseStyle . Render ( m . table . View ( ) )
2022-10-16 18:22:34 +02:00
}
2024-02-19 21:12:04 +01:00
func getRowsFromAiSuggestions ( ctx context . Context , columnNames [ ] string , shellName , query string ) ( [ ] table . Row , [ ] * data . HistoryEntry , error ) {
suggestions , err := ai . DebouncedGetAiSuggestions ( ctx , shellName , strings . TrimPrefix ( query , "?" ) , 5 )
2023-11-12 02:41:24 +01:00
if err != nil {
2024-02-06 06:34:03 +01:00
hctx . GetLogger ( ) . Infof ( "failed to get AI query suggestions: %v" , err )
2023-11-12 02:41:24 +01:00
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 )
2024-02-07 05:04:21 +01:00
row , err := lib . BuildTableRow ( ctx , columnNames , entry , func ( s string ) string { return s } )
2023-11-12 02:41:24 +01:00
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
}
2024-02-19 21:12:04 +01:00
func getRows ( ctx context . Context , columnNames [ ] string , shellName , defaultFilter , 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 {
2024-02-19 21:12:04 +01:00
return getRowsFromAiSuggestions ( ctx , columnNames , shellName , query )
2023-11-12 02:41:24 +01:00
}
2024-01-08 06:06:22 +01:00
searchResults , err := lib . Search ( ctx , db , defaultFilter + " " + 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
2023-12-06 06:43:01 +01:00
var seenCommands = make ( map [ string ] bool )
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 ]
2023-12-06 06:43:01 +01:00
if config . FilterDuplicateCommands && entry != nil {
cmd := strings . TrimSpace ( entry . Command )
if seenCommands [ cmd ] {
continue
}
seenCommands [ cmd ] = true
2022-11-04 04:36:36 +01:00
}
2023-12-06 06:43:01 +01:00
2024-02-07 05:04:21 +01:00
row , err := lib . BuildTableRow ( ctx , columnNames , * entry , commandEscaper )
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-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
}
2024-02-07 05:04:21 +01:00
func commandEscaper ( cmd string ) string {
2024-07-07 04:21:34 +02:00
if ! strings . Contains ( cmd , "\n" ) && ! strings . Contains ( cmd , "\t" ) {
2024-02-07 05:04:21 +01:00
// No special escaping necessary
return cmd
}
return fmt . Sprintf ( "%#v" , cmd )
}
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
2024-02-19 21:12:04 +01:00
func makeTableColumns ( ctx context . Context , shellName string , 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 {
2024-02-19 21:12:04 +01:00
allRows , _ , err := getRows ( ctx , columnNames , shellName , hctx . GetConf ( ctx ) . DefaultFilter , "" , 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 )
}
2024-02-19 21:12:04 +01:00
return makeTableColumns ( ctx , shellName , columnNames , allRows )
2022-11-12 00:57:13 +01:00
}
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 ) )
2023-12-03 22:15:38 +01:00
totalWidth := ( len ( columnWidths ) + 1 ) * 2 // The amount of space needed for the table padding
2022-10-27 06:48:36 +02:00
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 {
2024-02-19 21:12:04 +01:00
bigRows , _ , err := getRows ( ctx , columnNames , shellName , "" , "" , 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 ]
}
}
2023-12-03 22:15:38 +01:00
columnWidths [ largestColumnIdx ] -= 1
totalWidth -= 1
2022-10-27 07:11:07 +02:00
}
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
2024-02-19 21:12:04 +01:00
func makeTable ( ctx context . Context , shellName string , rows [ ] table . Row ) ( table . Model , error ) {
2022-10-27 07:11:07 +02:00
config := hctx . GetConf ( ctx )
2024-02-19 21:12:04 +01:00
columns , err := makeTableColumns ( ctx , shellName , 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 {
2024-04-28 22:50:09 +02:00
LineUp : loadedKeyBindings . Up ,
LineDown : loadedKeyBindings . Down ,
PageUp : loadedKeyBindings . PageUp ,
PageDown : loadedKeyBindings . 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" ) ,
) ,
2024-04-28 22:50:09 +02:00
MoveLeft : loadedKeyBindings . TableLeft ,
MoveRight : loadedKeyBindings . 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
}
2023-12-04 06:56:45 +01:00
tuiSize := 12
if isCompactHeightMode ( ) {
tuiSize -= 2
}
2023-12-07 01:12:41 +01:00
if isExtraCompactHeightMode ( ) {
tuiSize -= 3
}
2023-12-04 06:56:45 +01:00
tableHeight := min ( TABLE_HEIGHT , terminalHeight - tuiSize )
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 ( ) ) .
2023-12-19 05:32:11 +01:00
BorderForeground ( lipgloss . Color ( config . ColorScheme . BorderColor ) ) .
2022-10-16 18:22:34 +02:00
BorderBottom ( true ) .
Bold ( false )
s . Selected = s . Selected .
2023-12-19 05:32:11 +01:00
Foreground ( lipgloss . Color ( config . ColorScheme . SelectedText ) ) .
Background ( lipgloss . Color ( config . ColorScheme . SelectedBackground ) ) .
2022-10-16 18:22:34 +02:00
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.
2023-12-19 23:01:04 +01:00
hctx . GetLogger ( ) . Infof ( "Failed to compile regex %#v for query %#v, disabling highlighting of matches" , queryRegex , CURRENT_QUERY_FOR_HIGHLIGHTING )
2023-09-30 03:21:23 +02:00
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 ]
2023-12-19 23:01:04 +01:00
match := value [ matchStartIdx : matchEndIdx ]
2023-09-30 03:21:23 +02:00
if beforeMatch != "" {
2023-12-19 23:01:04 +01:00
ret += renderChunk ( beforeMatch , false , lastIncludedIdx == 0 , lastIncludedIdx + 1 == len ( value ) )
}
if match != "" {
ret += renderChunk ( match , true , matchStartIdx == 0 , matchEndIdx == len ( value ) )
2023-09-30 03:21:23 +02:00
}
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-12-31 22:00:56 +01:00
func configureColorProfile ( ctx context . Context ) {
2023-12-19 05:32:11 +01:00
if hctx . GetConf ( ctx ) . ColorScheme == hctx . GetDefaultColorScheme ( ) {
// Set termenv.ANSI for the default color scheme, so that we preserve
// the true default color scheme of hishtory which was initially
// configured with termenv.ANSI (even though we want to support
// full colors) for custom color schemes.
lipgloss . SetColorProfile ( termenv . ANSI )
2023-12-31 22:00:56 +01:00
return
}
if os . Getenv ( "HISHTORY_TEST" ) != "" {
2023-12-19 05:32:11 +01:00
// We also set termenv.ANSI for tests so as to ensure that all our
// test environments behave the same (by default, github actions
// ubuntu and macos have different termenv support).
lipgloss . SetColorProfile ( termenv . ANSI )
2023-12-31 22:00:56 +01:00
return
}
// When the shell launches control-R it isn't hooked up to the main TTY,
// which means that termenv isn't able to accurately detect color support
// in the current terminal. We set the environment variable _hishtory_tui_color
// to an int representing the termenv. If it is unset or set to 0, then we don't
// know the current color support, and we have to guess it. This means we
// risk either:
// * Choosing too high of a color support, and breaking hishtory colors
// in certain terminals
// * Choosing too low of a color support, and ending up with truncating
// customized colors
//
// The default terminal app on MacOS only supports termenv.ANSI256 (8 bit
// colors), which means we likely shouldn't default to TrueColor. From
// my own digging, I can't find any modern terminals that don't support
// termenv.ANSI256, so it seems like a reasonable default here.
colorProfileStr := os . Getenv ( "_hishtory_tui_color" )
if colorProfileStr == "" {
// Fall back to the default
2023-12-29 04:24:51 +01:00
lipgloss . SetColorProfile ( termenv . ANSI256 )
2023-12-31 22:00:56 +01:00
return
2023-12-19 05:32:11 +01:00
}
2023-12-31 22:00:56 +01:00
colorProfileInt , err := strconv . Atoi ( colorProfileStr )
if err != nil {
colorProfileInt = 0
}
// The int mappings for this are defined in query.go
switch colorProfileInt {
case 1 :
lipgloss . SetColorProfile ( termenv . TrueColor )
case 2 :
lipgloss . SetColorProfile ( termenv . ANSI256 )
case 3 :
lipgloss . SetColorProfile ( termenv . ANSI )
case 4 :
lipgloss . SetColorProfile ( termenv . Ascii )
default :
fallthrough
case 0 :
// Unknown, so fall back to the default
lipgloss . SetColorProfile ( termenv . ANSI256 )
}
}
2024-02-19 21:12:04 +01:00
func TuiQuery ( ctx context . Context , shellName , initialQuery string ) error {
2024-04-28 22:50:09 +02:00
loadedKeyBindings = hctx . GetConf ( ctx ) . KeyBindings . ToKeyMap ( )
2023-12-31 22:00:56 +01:00
configureColorProfile ( ctx )
2024-04-14 18:26:14 +02:00
p := tea . NewProgram ( initialModel ( ctx , shellName , initialQuery ) , tea . WithOutput ( os . Stderr ) )
2023-09-19 04:35:53 +02:00
// Async: Get the initial set of rows
go func ( ) {
2023-10-26 05:07:09 +02:00
LAST_DISPATCHED_QUERY_ID ++
2023-12-10 00:51:04 +01:00
queryId := LAST_DISPATCHED_QUERY_ID
2023-11-19 15:12:39 +01:00
LAST_DISPATCHED_QUERY_TIMESTAMP = time . Now ( )
2024-01-08 06:06:22 +01:00
conf := hctx . GetConf ( ctx )
2024-02-19 21:12:04 +01:00
rows , entries , err := getRows ( ctx , conf . DisplayedColumns , shellName , conf . DefaultFilter , 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 := ""
2024-02-19 21:12:04 +01:00
rows , entries , err := getRows ( ctx , hctx . GetConf ( ctx ) . DisplayedColumns , shellName , conf . DefaultFilter , 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
}
2024-02-07 05:04:21 +01:00
fmt . Printf ( "%s\n" , SELECTED_COMMAND )
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