hishtory/client/tui/tui.go

1020 lines
31 KiB
Go

package tui
import (
"context"
"fmt"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
_ "embed" // for embedding config.sh
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/ddworken/hishtory/client/ai"
"github.com/ddworken/hishtory/client/data"
"github.com/ddworken/hishtory/client/hctx"
"github.com/ddworken/hishtory/client/lib"
"github.com/ddworken/hishtory/client/table"
"github.com/ddworken/hishtory/shared"
"github.com/muesli/termenv"
"golang.org/x/term"
)
const TABLE_HEIGHT = 20
const PADDED_NUM_ENTRIES = TABLE_HEIGHT * 5
var CURRENT_QUERY_FOR_HIGHLIGHTING string = ""
var SELECTED_COMMAND string = ""
// 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_DISPATCHED_QUERY_TIMESTAMP time.Time
var LAST_PROCESSED_QUERY_ID = -1
type keyMap struct {
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
JumpStartOfInput key.Binding
JumpEndOfInput key.Binding
JumpWordLeft key.Binding
JumpWordRight key.Binding
}
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{
{fakeTitleKeyBinding, k.Up, k.Left, k.SelectEntry, k.SelectEntryAndChangeDir},
{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"),
key.WithHelp("↑ ", "scroll up "),
),
Down: key.NewBinding(
key.WithKeys("down", "alt+OB", "ctrl+n"),
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 "),
),
SelectEntry: key.NewBinding(
key.WithKeys("enter"),
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"),
),
Left: key.NewBinding(
key.WithKeys("left"),
key.WithHelp("← ", "move left "),
),
Right: key.NewBinding(
key.WithKeys("right"),
key.WithHelp("→ ", "move right "),
),
TableLeft: key.NewBinding(
key.WithKeys("shift+left"),
key.WithHelp("shift+← ", "scroll the table left "),
),
TableRight: key.NewBinding(
key.WithKeys("shift+right"),
key.WithHelp("shift+→ ", "scroll the table right "),
),
DeleteEntry: key.NewBinding(
key.WithKeys("ctrl+k"),
key.WithHelp("ctrl+k", "delete the highlighted entry "),
),
Help: key.NewBinding(
key.WithKeys("ctrl+h"),
key.WithHelp("ctrl+h", "help "),
),
Quit: key.NewBinding(
key.WithKeys("esc", "ctrl+c", "ctrl+d"),
key.WithHelp("esc", "exit hiSHtory "),
),
JumpStartOfInput: key.NewBinding(
key.WithKeys("ctrl+a"),
key.WithHelp("ctrl+a", "jump to the start of the input "),
),
JumpEndOfInput: key.NewBinding(
key.WithKeys("ctrl+e"),
key.WithHelp("ctrl+e", "jump to the end of the input "),
),
JumpWordLeft: key.NewBinding(
key.WithKeys("ctrl+left"),
key.WithHelp("ctrl+left", "jump left one word "),
),
JumpWordRight: key.NewBinding(
key.WithKeys("ctrl+right"),
key.WithHelp("ctrl+right", "jump right one word "),
),
}
type SelectStatus int64
const (
NotSelected SelectStatus = iota
Selected
SelectedWithChangeDir
)
type model struct {
// 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
// Model for the help bar at the bottom of the page
help help.Model
// Whether the TUI is quitting.
quitting bool
// The table used for displaying search results. Nil if the initial search query hasn't returned yet.
table *table.Model
// The entries in the table
tableEntries []*data.HistoryEntry
// Whether the user has hit enter to select an entry and the TUI is thus about to quit.
selected SelectStatus
// The search box for the query
queryInput textinput.Model
// The query to run. Reset to nil after it was run.
runQuery *string
// The previous query that was run.
lastQuery string
// Unrecoverable error.
fatalErr error
// 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
// The currently executing shell. Defaults to bash if not specified. Used for more precise AI suggestions.
shellName string
}
type doneDownloadingMsg struct{}
type offlineMsg struct{}
type bannerMsg struct {
banner string
}
type asyncQueryFinishedMsg struct {
// The query ID finished running. Used to ensure that we only process this message if it is the latest query to finish.
queryId int
// 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.
overriddenSearchQuery *string
}
func initialModel(ctx context.Context, shellName, initialQuery string) model {
s := spinner.New()
s.Spinner = spinner.Dot
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
queryInput := textinput.New()
defaultFilter := hctx.GetConf(ctx).DefaultFilter
if defaultFilter != "" {
queryInput.Prompt = "[" + defaultFilter + "] "
}
queryInput.PromptStyle = queryInput.PlaceholderStyle
if defaultFilter == "" {
queryInput.Placeholder = "ls"
}
queryInput.Focus()
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
}
if initialQuery != "" {
queryInput.SetValue(initialQuery)
}
CURRENT_QUERY_FOR_HIGHLIGHTING = initialQuery
return model{ctx: ctx, spinner: s, isLoading: true, table: nil, tableEntries: []*data.HistoryEntry{}, runQuery: &initialQuery, queryInput: queryInput, help: help.New(), shellName: shellName}
}
func (m model) Init() tea.Cmd {
return m.spinner.Tick
}
func updateTable(m model, rows []table.Row, entries []*data.HistoryEntry, searchErr error, forceUpdateTable, maintainCursor bool) model {
if m.runQuery == nil {
m.runQuery = &m.lastQuery
}
m.searchErr = searchErr
if searchErr != nil {
return m
}
m.tableEntries = entries
initialCursor := 0
if m.table != nil {
initialCursor = m.table.Cursor()
}
if forceUpdateTable || m.table == nil {
t, err := makeTable(m.ctx, m.shellName, rows)
if err != nil {
m.fatalErr = err
return m
}
m.table = &t
}
m.table.SetRows(rows)
if maintainCursor {
m.table.SetCursor(initialCursor)
} else {
m.table.SetCursor(0)
}
m.lastQuery = *m.runQuery
m.runQuery = 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)
}
return m
}
func preventTableOverscrolling(m model) {
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)
}
}
}
func runQueryAndUpdateTable(m model, forceUpdateTable, maintainCursor bool) tea.Cmd {
if (m.runQuery != nil && *m.runQuery != m.lastQuery) || forceUpdateTable || m.searchErr != nil {
query := m.lastQuery
if m.runQuery != nil {
query = *m.runQuery
}
LAST_DISPATCHED_QUERY_ID++
queryId := LAST_DISPATCHED_QUERY_ID
LAST_DISPATCHED_QUERY_TIMESTAMP = time.Now()
return func() tea.Msg {
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 = ""
}
rows, entries, searchErr := getRows(m.ctx, conf.DisplayedColumns, m.shellName, defaultFilter, query, PADDED_NUM_ENTRIES)
return asyncQueryFinishedMsg{queryId, rows, entries, searchErr, forceUpdateTable, maintainCursor, nil}
}
}
return nil
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, keys.Quit):
m.quitting = true
return m, tea.Quit
case key.Matches(msg, keys.SelectEntry):
if len(m.tableEntries) != 0 && m.table != nil {
m.selected = Selected
}
return m, tea.Quit
case key.Matches(msg, keys.SelectEntryAndChangeDir):
if len(m.tableEntries) != 0 && m.table != nil {
m.selected = SelectedWithChangeDir
}
return m, tea.Quit
case key.Matches(msg, keys.DeleteEntry):
if m.table == nil {
return m, nil
}
err := deleteHistoryEntry(m.ctx, *m.tableEntries[m.table.Cursor()])
if err != nil {
m.fatalErr = err
return m, nil
}
cmd := runQueryAndUpdateTable(m, true, true)
preventTableOverscrolling(m)
return m, cmd
case key.Matches(msg, keys.Help):
m.help.ShowAll = !m.help.ShowAll
return m, nil
case key.Matches(msg, keys.JumpStartOfInput):
m.queryInput.SetCursor(0)
return m, nil
case key.Matches(msg, keys.JumpEndOfInput):
m.queryInput.SetCursor(len(m.queryInput.Value()))
return m, nil
case key.Matches(msg, keys.JumpWordLeft):
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
case key.Matches(msg, keys.JumpWordRight):
wordBoundaries := calculateWordBoundaries(m.queryInput.Value())
for _, boundary := range wordBoundaries {
if boundary > m.queryInput.Position() {
m.queryInput.SetCursor(boundary)
break
}
}
return m, nil
default:
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)
}
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
}
i, cmd2 := m.queryInput.Update(msg)
m.queryInput = i
searchQuery := m.queryInput.Value()
m.runQuery = &searchQuery
CURRENT_QUERY_FOR_HIGHLIGHTING = searchQuery
cmd3 := runQueryAndUpdateTable(m, forceUpdateTable, false)
preventTableOverscrolling(m)
return m, tea.Batch(pendingCommands, cmd2, cmd3)
}
case tea.WindowSizeMsg:
m.help.Width = msg.Width
m.queryInput.Width = msg.Width
cmd := runQueryAndUpdateTable(m, true, true)
return m, cmd
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
case asyncQueryFinishedMsg:
if msg.queryId > LAST_PROCESSED_QUERY_ID {
LAST_PROCESSED_QUERY_ID = msg.queryId
m = updateTable(m, msg.rows, msg.entries, msg.searchErr, msg.forceUpdateTable, msg.maintainCursor)
if msg.overriddenSearchQuery != nil {
m.queryInput.SetValue(*msg.overriddenSearchQuery)
}
}
return m, nil
default:
var cmd tea.Cmd
if m.isLoading {
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
} else {
if m.table != nil {
t, cmd := m.table.Update(msg)
m.table = &t
return m, cmd
}
return m, nil
}
}
}
func calculateWordBoundaries(input string) []int {
ret := make([]int, 0)
ret = append(ret, 0)
prevWasBreaking := false
for idx, char := range input {
if char == ' ' || char == '-' {
if !prevWasBreaking {
ret = append(ret, idx)
}
prevWasBreaking = true
} else {
prevWasBreaking = false
}
}
if !prevWasBreaking {
ret = append(ret, len(input))
}
return ret
}
func (m model) View() string {
if m.fatalErr != nil {
return fmt.Sprintf("An unrecoverable error occured: %v\n", m.fatalErr)
}
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
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)
}
}
SELECTED_COMMAND = "cd \"" + changeDir + "\" && " + SELECTED_COMMAND
}
return ""
}
if m.quitting {
return ""
}
additionalMessages := make([]string, 0)
if m.isLoading {
additionalMessages = append(additionalMessages, fmt.Sprintf("%s Loading hishtory entries from other devices...", m.spinner.View()))
}
if m.isOffline {
additionalMessages = append(additionalMessages, "Warning: failed to contact the hishtory backend (are you offline?), so some results may be stale")
}
if m.searchErr != nil {
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()))
}
additionalMessagesStr := strings.Join(additionalMessages, "\n") + "\n"
if isExtraCompactHeightMode() {
additionalMessagesStr = "\n"
}
helpView := m.help.View(keys)
if isExtraCompactHeightMode() {
helpView = ""
}
additionalSpacing := "\n"
if isCompactHeightMode() {
additionalSpacing = ""
}
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
}
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
}
func getBaseStyle(config hctx.ClientConfig) lipgloss.Style {
return lipgloss.NewStyle().
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color(config.ColorScheme.BorderColor))
}
func renderNullableTable(m model, helpText string) string {
if m.table == nil {
return strings.Repeat("\n", TABLE_HEIGHT+3)
}
helpTextLen := strings.Count(helpText, "\n")
baseStyle := getBaseStyle(*hctx.GetConf(m.ctx))
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")
}
return baseStyle.Render(m.table.View())
}
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)
if err != nil {
hctx.GetLogger().Infof("failed to get AI query suggestions: %v", err)
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, func(s string) string { return s })
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
}
func getRows(ctx context.Context, columnNames []string, shellName, defaultFilter, query string, numEntries int) ([]table.Row, []*data.HistoryEntry, error) {
db := hctx.GetDb(ctx)
config := hctx.GetConf(ctx)
if config.AiCompletion && !config.IsOffline && strings.HasPrefix(query, "?") && len(query) > 1 {
return getRowsFromAiSuggestions(ctx, columnNames, shellName, query)
}
searchResults, err := lib.Search(ctx, db, defaultFilter+" "+query, numEntries)
if err != nil {
return nil, nil, err
}
var rows []table.Row
var filteredData []*data.HistoryEntry
var seenCommands = make(map[string]bool)
for i := 0; i < numEntries; i++ {
if i < len(searchResults) {
entry := searchResults[i]
if config.FilterDuplicateCommands && entry != nil {
cmd := strings.TrimSpace(entry.Command)
if seenCommands[cmd] {
continue
}
seenCommands[cmd] = true
}
row, err := lib.BuildTableRow(ctx, columnNames, *entry, commandEscaper)
if err != nil {
return nil, nil, fmt.Errorf("failed to build row for entry=%#v: %w", entry, err)
}
rows = append(rows, row)
filteredData = append(filteredData, entry)
} else {
rows = append(rows, table.Row{})
}
}
return rows, filteredData, nil
}
func commandEscaper(cmd string) string {
if !strings.Contains(cmd, "\n") {
// No special escaping necessary
return cmd
}
return fmt.Sprintf("%#v", cmd)
}
func calculateColumnWidths(rows []table.Row, numColumns int) []int {
neededColumnWidth := make([]int, numColumns)
for _, row := range rows {
for i, v := range row {
neededColumnWidth[i] = max(neededColumnWidth[i], len(v))
}
}
return neededColumnWidth
}
func getTerminalSize() (int, int, error) {
return term.GetSize(2)
}
var bigQueryResults []table.Row
func makeTableColumns(ctx context.Context, shellName string, columnNames []string, rows []table.Row) ([]table.Column, error) {
// Handle an initial query with no results
if len(rows) == 0 || len(rows[0]) == 0 {
allRows, _, err := getRows(ctx, columnNames, shellName, hctx.GetConf(ctx).DefaultFilter, "", 25)
if err != nil {
return nil, err
}
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)
}
return makeTableColumns(ctx, shellName, columnNames, allRows)
}
// Calculate the minimum amount of space that we need for each column for the current actual search
columnWidths := calculateColumnWidths(rows, len(columnNames))
totalWidth := (len(columnWidths) + 1) * 2 // The amount of space needed for the table padding
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 {
bigRows, _, err := getRows(ctx, columnNames, shellName, "", "", 1000)
if err != nil {
return nil, err
}
bigQueryResults = bigRows
}
maximumColumnWidths := calculateColumnWidths(bigQueryResults, len(columnNames))
// Get the actual terminal width. If we're below this, opportunistically add some padding aiming for the maximum column widths
terminalWidth, _, err := getTerminalSize()
if err != nil {
return nil, fmt.Errorf("failed to get terminal size: %w", err)
}
for totalWidth < (terminalWidth - len(columnNames)) {
prevTotalWidth := totalWidth
for i := range columnNames {
if columnWidths[i] < maximumColumnWidths[i]+5 {
columnWidths[i] += 1
totalWidth += 1
}
}
if totalWidth == prevTotalWidth {
break
}
}
// 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] -= 1
totalWidth -= 1
}
// And finally, create some actual columns!
columns := make([]table.Column, 0)
for i, name := range columnNames {
columns = append(columns, table.Column{Title: name, Width: columnWidths[i]})
}
return columns, nil
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func makeTable(ctx context.Context, shellName string, rows []table.Row) (table.Model, error) {
config := hctx.GetConf(ctx)
columns, err := makeTableColumns(ctx, shellName, config.DisplayedColumns, rows)
if err != nil {
return table.Model{}, err
}
km := table.KeyMap{
LineUp: keys.Up,
LineDown: keys.Down,
PageUp: keys.PageUp,
PageDown: keys.PageDown,
GotoTop: key.NewBinding(
key.WithKeys("home"),
key.WithHelp("home", "go to start"),
),
GotoBottom: key.NewBinding(
key.WithKeys("end"),
key.WithHelp("end", "go to end"),
),
MoveLeft: keys.TableLeft,
MoveRight: keys.TableRight,
}
_, terminalHeight, err := getTerminalSize()
if err != nil {
return table.Model{}, err
}
tuiSize := 12
if isCompactHeightMode() {
tuiSize -= 2
}
if isExtraCompactHeightMode() {
tuiSize -= 3
}
tableHeight := min(TABLE_HEIGHT, terminalHeight-tuiSize)
t := table.New(
table.WithColumns(columns),
table.WithRows(rows),
table.WithFocused(true),
table.WithHeight(tableHeight),
table.WithKeyMap(km),
)
s := table.DefaultStyles()
s.Header = s.Header.
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color(config.ColorScheme.BorderColor)).
BorderBottom(true).
Bold(false)
s.Selected = s.Selected.
Foreground(lipgloss.Color(config.ColorScheme.SelectedText)).
Background(lipgloss.Color(config.ColorScheme.SelectedBackground)).
Bold(false)
if config.HighlightMatches {
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.
hctx.GetLogger().Infof("Failed to compile regex %#v for query %#v, disabling highlighting of matches", queryRegex, CURRENT_QUERY_FOR_HIGHLIGHTING)
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]
match := value[matchStartIdx:matchEndIdx]
if beforeMatch != "" {
ret += renderChunk(beforeMatch, false, lastIncludedIdx == 0, lastIncludedIdx+1 == len(value))
}
if match != "" {
ret += renderChunk(match, true, matchStartIdx == 0, matchEndIdx == len(value))
}
lastIncludedIdx = matchEndIdx
}
if lastIncludedIdx != len(value) {
ret += renderChunk(value[lastIncludedIdx:], false, false, true)
}
return ret
}
}
t.SetStyles(s)
t.Focus()
return t, nil
}
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
config := hctx.GetConf(ctx)
if config.IsOffline {
return nil
}
dr := shared.DeletionRequest{
UserId: data.UserId(hctx.GetConf(ctx).UserSecret),
SendTime: time.Now(),
}
dr.Messages.Ids = append(dr.Messages.Ids,
shared.MessageIdentifier{DeviceId: entry.DeviceId, EndTime: entry.EndTime, EntryId: entry.EntryId},
)
return lib.SendDeletionRequest(ctx, dr)
}
func configureColorProfile(ctx context.Context) {
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)
return
}
if os.Getenv("HISHTORY_TEST") != "" {
// 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)
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
lipgloss.SetColorProfile(termenv.ANSI256)
return
}
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)
}
}
func TuiQuery(ctx context.Context, shellName, initialQuery string) error {
configureColorProfile(ctx)
p := tea.NewProgram(initialModel(ctx, shellName, initialQuery), tea.WithOutput(os.Stderr))
// Async: Get the initial set of rows
go func() {
LAST_DISPATCHED_QUERY_ID++
queryId := LAST_DISPATCHED_QUERY_ID
LAST_DISPATCHED_QUERY_TIMESTAMP = time.Now()
conf := hctx.GetConf(ctx)
rows, entries, err := getRows(ctx, conf.DisplayedColumns, shellName, conf.DefaultFilter, initialQuery, PADDED_NUM_ENTRIES)
if err == nil || initialQuery == "" {
p.Send(asyncQueryFinishedMsg{queryId: queryId, rows: rows, entries: entries, searchErr: err, forceUpdateTable: true, maintainCursor: false, overriddenSearchQuery: nil})
} else {
// initialQuery is likely invalid in some way, let's just drop it
emptyQuery := ""
rows, entries, err := getRows(ctx, hctx.GetConf(ctx).DisplayedColumns, shellName, conf.DefaultFilter, emptyQuery, PADDED_NUM_ENTRIES)
p.Send(asyncQueryFinishedMsg{queryId: queryId, rows: rows, entries: entries, searchErr: err, forceUpdateTable: true, maintainCursor: false, overriddenSearchQuery: &emptyQuery})
}
}()
// Async: Retrieve additional entries from the backend
go func() {
err := lib.RetrieveAdditionalEntriesFromRemote(ctx, "tui")
if err != nil {
p.Send(err)
}
p.Send(doneDownloadingMsg{})
}()
// Async: Process deletion requests
go func() {
err := lib.ProcessDeletionRequests(ctx)
if err != nil {
p.Send(err)
}
}()
// Async: Check for any banner from the server
go func() {
banner, err := lib.GetBanner(ctx)
if err != nil {
if lib.IsOfflineError(ctx, err) {
p.Send(offlineMsg{})
} else {
p.Send(err)
}
}
p.Send(bannerMsg{banner: string(banner)})
}()
// Blocking: Start the TUI
_, err := p.Run()
if err != nil {
return err
}
if SELECTED_COMMAND == "" && os.Getenv("HISHTORY_TERM_INTEGRATION") != "" {
// Print out the initialQuery instead so that we don't clear the terminal
SELECTED_COMMAND = initialQuery
}
fmt.Printf("%s\n", SELECTED_COMMAND)
return nil
}
// TODO: support custom key bindings
// TODO: make the help page wrap