mirror of
https://github.com/ddworken/hishtory.git
synced 2024-11-23 00:34:27 +01:00
03363efbf6
This is a tricky bug to fix because the width of a tab character varies depending on context. This means that when we're trying to build a table and calculating the width of columns for budgeting, we can't actually know the width of a tab without knowing exactly what characters come before it. This is in theory doable, but it leads to some really complex code that I'd rather not adopt.
907 lines
28 KiB
Go
907 lines
28 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/client/tui/keybindings"
|
|
"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 SelectStatus int64
|
|
|
|
const (
|
|
NotSelected SelectStatus = iota
|
|
Selected
|
|
SelectedWithChangeDir
|
|
)
|
|
|
|
var loadedKeyBindings keybindings.KeyMap = keybindings.DefaultKeyMap
|
|
|
|
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
|
|
preventTableOverscrolling(m)
|
|
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, loadedKeyBindings.Quit):
|
|
m.quitting = true
|
|
return m, tea.Quit
|
|
case key.Matches(msg, loadedKeyBindings.SelectEntry):
|
|
if len(m.tableEntries) != 0 && m.table != nil {
|
|
m.selected = Selected
|
|
}
|
|
return m, tea.Quit
|
|
case key.Matches(msg, loadedKeyBindings.SelectEntryAndChangeDir):
|
|
if len(m.tableEntries) != 0 && m.table != nil {
|
|
m.selected = SelectedWithChangeDir
|
|
}
|
|
return m, tea.Quit
|
|
case key.Matches(msg, loadedKeyBindings.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, loadedKeyBindings.Help):
|
|
m.help.ShowAll = !m.help.ShowAll
|
|
return m, nil
|
|
case key.Matches(msg, loadedKeyBindings.JumpStartOfInput):
|
|
m.queryInput.SetCursor(0)
|
|
return m, nil
|
|
case key.Matches(msg, loadedKeyBindings.JumpEndOfInput):
|
|
m.queryInput.SetCursor(len(m.queryInput.Value()))
|
|
return m, nil
|
|
case key.Matches(msg, loadedKeyBindings.WordLeft):
|
|
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, loadedKeyBindings.WordRight):
|
|
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(loadedKeyBindings)
|
|
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") && !strings.Contains(cmd, "\t") {
|
|
// 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: loadedKeyBindings.Up,
|
|
LineDown: loadedKeyBindings.Down,
|
|
PageUp: loadedKeyBindings.PageUp,
|
|
PageDown: loadedKeyBindings.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: loadedKeyBindings.TableLeft,
|
|
MoveRight: loadedKeyBindings.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 {
|
|
loadedKeyBindings = hctx.GetConf(ctx).KeyBindings.ToKeyMap()
|
|
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
|