hishtory/client/lib/tui.go

570 lines
15 KiB
Go

package lib
import (
"context"
"fmt"
"os"
"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/data"
"github.com/ddworken/hishtory/client/hctx"
"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 selectedCommand string = ""
var baseStyle = lipgloss.NewStyle().
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("240"))
type keyMap struct {
Up key.Binding
Down key.Binding
PageUp key.Binding
PageDown key.Binding
SelectEntry key.Binding
Left key.Binding
Right key.Binding
TableLeft key.Binding
TableRight key.Binding
DeleteEntry key.Binding
Help key.Binding
Quit 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},
{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"),
),
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"),
),
}
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.
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 bool
// 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
}
type doneDownloadingMsg struct{}
type offlineMsg struct{}
type bannerMsg struct {
banner string
}
func initialModel(ctx *context.Context, t table.Model, tableEntries []*data.HistoryEntry, initialQuery string) model {
s := spinner.New()
s.Spinner = spinner.Dot
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
queryInput := textinput.New()
queryInput.Placeholder = "ls"
queryInput.Focus()
queryInput.CharLimit = 156
queryInput.Width = 50
if initialQuery != "" {
queryInput.SetValue(initialQuery)
}
return model{ctx: ctx, spinner: s, isLoading: true, table: t, tableEntries: tableEntries, runQuery: &initialQuery, queryInput: queryInput, help: help.New()}
}
func (m model) Init() tea.Cmd {
return m.spinner.Tick
}
func runQueryAndUpdateTable(m model, updateTable bool) model {
if (m.runQuery != nil && *m.runQuery != m.lastQuery) || updateTable || m.searchErr != nil {
if m.runQuery == nil {
m.runQuery = &m.lastQuery
}
rows, entries, err := getRows(m.ctx, hctx.GetConf(m.ctx).DisplayedColumns, *m.runQuery, PADDED_NUM_ENTRIES)
m.searchErr = err
if err != nil {
return m
}
m.tableEntries = entries
if updateTable {
t, err := makeTable(m.ctx, rows)
if err != nil {
m.fatalErr = err
return m
}
m.table = t
}
m.table.SetRows(rows)
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 (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.selected = true
}
return m, tea.Quit
case key.Matches(msg, keys.DeleteEntry):
err := deleteHistoryEntry(m.ctx, *m.tableEntries[m.table.Cursor()])
if err != nil {
m.fatalErr = err
return m, nil
}
m = runQueryAndUpdateTable(m, true)
return m, nil
case key.Matches(msg, keys.Help):
m.help.ShowAll = !m.help.ShowAll
return m, nil
default:
t, cmd1 := m.table.Update(msg)
m.table = t
if strings.HasPrefix(msg.String(), "alt+") {
return m, tea.Batch(cmd1)
}
i, cmd2 := m.queryInput.Update(msg)
m.queryInput = i
searchQuery := m.queryInput.Value()
m.runQuery = &searchQuery
m = runQueryAndUpdateTable(m, false)
return m, tea.Batch(cmd1, cmd2)
}
case tea.WindowSizeMsg:
m.help.Width = msg.Width
m = runQueryAndUpdateTable(m, true)
return m, nil
case offlineMsg:
m.isOffline = true
return m, nil
case bannerMsg:
m.banner = msg.banner
return m, nil
case doneDownloadingMsg:
m.isLoading = false
return m, nil
default:
var cmd tea.Cmd
if m.isLoading {
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
} else {
m.table, cmd = m.table.Update(msg)
return m, cmd
}
}
}
func (m model) View() string {
if m.fatalErr != nil {
return fmt.Sprintf("An unrecoverable error occured: %v\n", m.fatalErr)
}
if m.selected {
selectedCommand = m.tableEntries[m.table.Cursor()].Command
return ""
}
if m.quitting {
return ""
}
loadingMessage := ""
if m.isLoading {
loadingMessage = fmt.Sprintf("%s Loading hishtory entries from other devices...", m.spinner.View())
}
warning := ""
if m.isOffline {
warning += "Warning: failed to contact the hishtory backend (are you offline?), so some results may be stale\n\n"
}
if m.searchErr != nil {
warning += fmt.Sprintf("Warning: failed to search: %v\n\n", m.searchErr)
}
helpView := m.help.View(keys)
return fmt.Sprintf("\n%s\n%s%s\nSearch Query: %s\n\n%s\n", loadingMessage, warning, m.banner, m.queryInput.View(), baseStyle.Render(m.table.View())) + helpView
}
func getRows(ctx *context.Context, columnNames []string, query string, numEntries int) ([]table.Row, []*data.HistoryEntry, error) {
db := hctx.GetDb(ctx)
config := hctx.GetConf(ctx)
searchResults, err := Search(ctx, db, query, numEntries)
if err != nil {
return nil, nil, err
}
var rows []table.Row
var filteredData []*data.HistoryEntry
lastCommand := ""
for i := 0; i < numEntries; i++ {
if i < len(searchResults) {
entry := searchResults[i]
if strings.TrimSpace(entry.Command) == strings.TrimSpace(lastCommand) && config.FilterDuplicateCommands {
continue
}
entry.Command = strings.ReplaceAll(entry.Command, "\n", "\\n")
row, err := buildTableRow(ctx, columnNames, *entry)
if err != nil {
return nil, nil, fmt.Errorf("failed to build row for entry=%#v: %v", entry, err)
}
rows = append(rows, row)
filteredData = append(filteredData, entry)
lastCommand = entry.Command
} else {
rows = append(rows, table.Row{})
}
}
return rows, filteredData, nil
}
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, 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, "", 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, 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 := 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 {
bigRows, _, err := getRows(ctx, columnNames, "", 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: %v", 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] -= 2
totalWidth -= 2
}
// 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, rows []table.Row) (table.Model, error) {
config := hctx.GetConf(ctx)
columns, err := makeTableColumns(ctx, 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
}
tableHeight := min(TABLE_HEIGHT, terminalHeight-12)
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("240")).
BorderBottom(true).
Bold(false)
s.Selected = s.Selected.
Foreground(lipgloss.Color("229")).
Background(lipgloss.Color("57")).
Bold(false)
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{Date: entry.EndTime, DeviceId: entry.DeviceId})
return SendDeletionRequest(dr)
}
func TuiQuery(ctx *context.Context, initialQuery string) error {
lipgloss.SetColorProfile(termenv.ANSI)
rows, entries, err := getRows(ctx, hctx.GetConf(ctx).DisplayedColumns, initialQuery, PADDED_NUM_ENTRIES)
if err != nil {
if initialQuery != "" {
// initialQuery is likely invalid in some way, let's just drop it
return TuiQuery(ctx, "")
}
// Something else has gone wrong, crash
return err
}
t, err := makeTable(ctx, rows)
if err != nil {
return err
}
p := tea.NewProgram(initialModel(ctx, t, entries, initialQuery), tea.WithOutput(os.Stderr))
// Async: Retrieve additional entries from the backend
go func() {
err := RetrieveAdditionalEntriesFromRemote(ctx)
if err != nil {
p.Send(err)
}
p.Send(doneDownloadingMsg{})
}()
// Async: Process deletion requests
go func() {
err := ProcessDeletionRequests(ctx)
if err != nil {
p.Send(err)
}
}()
// Async: Check for any banner from the server
go func() {
banner, err := GetBanner(ctx)
if err != nil {
if IsOfflineError(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 selectedCommand == "" && os.Getenv("HISHTORY_TERM_INTEGRATION") != "" {
// Print out the initialQuery instead so that we don't clear the terminal
selectedCommand = initialQuery
}
fmt.Printf("%s\n", strings.ReplaceAll(selectedCommand, "\\n", "\n"))
return nil
}