mirror of
https://github.com/ddworken/hishtory.git
synced 2024-11-28 03:03:42 +01:00
0023c72636
* Quote initial commands to make it possible to easily use hishtory to find matching entries for already typed commands that contain flags * Add test for quoting dashes * Fix test failures * More test fixes * Update goldens * Update goldens * Update goldens * Fix race condition * Fix test harness bug by swapping to splitn * Update goldens * Update golden * Update test
1002 lines
31 KiB
Go
1002 lines
31 KiB
Go
package tui
|
|
|
|
import (
|
|
"context"
|
|
_ "embed" // for embedding config.sh
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"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/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/muesli/termenv"
|
|
"golang.org/x/term"
|
|
)
|
|
|
|
var (
|
|
CURRENT_QUERY_FOR_HIGHLIGHTING string = ""
|
|
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
|
|
LAST_DISPATCHED_QUERY_TIMESTAMP time.Time
|
|
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
|
|
|
|
// Whether we've finished the first load of results. If we haven't, we refuse to run additional queries to avoid race conditions with how we handle invalid initial queries.
|
|
hasFinishedFirstLoad bool
|
|
}
|
|
|
|
type (
|
|
doneDownloadingMsg struct{}
|
|
offlineMsg struct{}
|
|
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
|
|
|
|
isFirstQuery bool
|
|
}
|
|
|
|
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()
|
|
cfg := hctx.GetConf(ctx)
|
|
defaultFilter := cfg.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().Warnf("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, hasFinishedFirstLoad: false}
|
|
}
|
|
|
|
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 {
|
|
// if !m.hasFinishedFirstLoad {
|
|
// return nil
|
|
// }
|
|
query := m.lastQuery
|
|
if m.runQuery != nil {
|
|
query = *m.runQuery
|
|
}
|
|
queryId := allocateQueryId()
|
|
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, getNumEntriesNeeded(m.ctx))
|
|
return asyncQueryFinishedMsg{queryId, rows, entries, searchErr, forceUpdateTable, maintainCursor, nil, false}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func sanitizeEscapeCodes(input string) string {
|
|
re := regexp.MustCompile(`\d\d;rgb:[0-9a-f]{4}/[0-9a-f]{4}/[0-9a-f]{4}`)
|
|
return re.ReplaceAllString(input, "")
|
|
}
|
|
|
|
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
m.queryInput.SetValue(sanitizeEscapeCodes(m.queryInput.Value()))
|
|
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)
|
|
}
|
|
}
|
|
if msg.isFirstQuery {
|
|
m.hasFinishedFirstLoad = true
|
|
}
|
|
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().Warnf("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(m.ctx) {
|
|
additionalMessagesStr = "\n"
|
|
}
|
|
helpView := m.help.View(loadedKeyBindings)
|
|
if isExtraCompactHeightMode(m.ctx) {
|
|
helpView = ""
|
|
}
|
|
additionalSpacing := "\n"
|
|
if isCompactHeightMode(m.ctx) {
|
|
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(ctx context.Context) bool {
|
|
if hctx.GetConf(ctx).ForceCompactMode {
|
|
return true
|
|
}
|
|
_, height, err := getTerminalSize()
|
|
if err != nil {
|
|
hctx.GetLogger().Warnf("got err=%v when retrieving terminal dimensions, assuming the terminal is reasonably tall", err)
|
|
return false
|
|
}
|
|
return height < 15
|
|
}
|
|
|
|
func isCompactHeightMode(ctx context.Context) bool {
|
|
if hctx.GetConf(ctx).ForceCompactMode {
|
|
return true
|
|
}
|
|
_, height, err := getTerminalSize()
|
|
if err != nil {
|
|
hctx.GetLogger().Warnf("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", getTableHeight(m.ctx)+3)
|
|
}
|
|
helpTextLen := strings.Count(helpText, "\n")
|
|
baseStyle := getBaseStyle(*hctx.GetConf(m.ctx))
|
|
if isCompactHeightMode(m.ctx) && 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().Warnf("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 && 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
|
|
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 getTableHeight(ctx context.Context) int {
|
|
config := hctx.GetConf(ctx)
|
|
if config.FullScreenRendering {
|
|
_, terminalHeight, err := getTerminalSize()
|
|
if err != nil {
|
|
// A reasonable guess at a default if for some reason we fail to retrieve the terminal size
|
|
return 30
|
|
}
|
|
return max(terminalHeight-15, 20)
|
|
}
|
|
// Default to 20 when not full-screen since we want to balance showing a large table with not using the entire screen
|
|
return 20
|
|
}
|
|
|
|
func getNumEntriesNeeded(ctx context.Context) int {
|
|
// Get more than table height since the TUI filters some out (e.g. duplicate entries)
|
|
return getTableHeight(ctx) * 5
|
|
}
|
|
|
|
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(ctx) {
|
|
tuiSize -= 2
|
|
}
|
|
if isExtraCompactHeightMode(ctx) {
|
|
tuiSize -= 3
|
|
}
|
|
tableHeight := min(getTableHeight(ctx), 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().Warnf("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
|
|
}
|
|
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 buildInitialQueryWithSearchEscaping(initialQueryArray []string) (string, error) {
|
|
var initialQuery string
|
|
|
|
for i, queryChunk := range initialQueryArray {
|
|
if i != 0 {
|
|
initialQuery += " "
|
|
}
|
|
if strings.HasPrefix(queryChunk, "-") {
|
|
quoted, err := json.Marshal(queryChunk)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to marshal query chunk for escaping: %w", err)
|
|
}
|
|
initialQuery += string(quoted)
|
|
} else {
|
|
initialQuery += queryChunk
|
|
}
|
|
}
|
|
|
|
return initialQuery, nil
|
|
}
|
|
|
|
func splitQueryArray(initialQueryArray []string) []string {
|
|
var splitQueryArray []string
|
|
for _, queryChunk := range initialQueryArray {
|
|
splitQueryArray = append(splitQueryArray, strings.Split(queryChunk, " ")...)
|
|
}
|
|
return splitQueryArray
|
|
}
|
|
|
|
func allocateQueryId() int {
|
|
LAST_DISPATCHED_QUERY_ID++
|
|
LAST_DISPATCHED_QUERY_TIMESTAMP = time.Now()
|
|
return LAST_DISPATCHED_QUERY_ID
|
|
}
|
|
|
|
func TuiQuery(ctx context.Context, shellName string, initialQueryArray []string) error {
|
|
initialQueryArray = splitQueryArray(initialQueryArray)
|
|
initialQueryWithEscaping, err := buildInitialQueryWithSearchEscaping(initialQueryArray)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
loadedKeyBindings = hctx.GetConf(ctx).KeyBindings.ToKeyMap()
|
|
configureColorProfile(ctx)
|
|
additionalOptions := []tea.ProgramOption{tea.WithOutput(os.Stderr)}
|
|
if hctx.GetConf(ctx).FullScreenRendering {
|
|
additionalOptions = append(additionalOptions, tea.WithAltScreen())
|
|
}
|
|
p := tea.NewProgram(initialModel(ctx, shellName, initialQueryWithEscaping), additionalOptions...)
|
|
// Async: Get the initial set of rows
|
|
go func() {
|
|
queryId := allocateQueryId()
|
|
conf := hctx.GetConf(ctx)
|
|
rows, entries, err := getRows(ctx, conf.DisplayedColumns, shellName, conf.DefaultFilter, initialQueryWithEscaping, getNumEntriesNeeded(ctx))
|
|
if err == nil || initialQueryWithEscaping == "" {
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
p.Send(asyncQueryFinishedMsg{queryId: queryId, rows: rows, entries: entries, searchErr: err, forceUpdateTable: true, maintainCursor: false, overriddenSearchQuery: nil, isFirstQuery: true})
|
|
} else {
|
|
// The initial query 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, getNumEntriesNeeded(ctx))
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
p.Send(asyncQueryFinishedMsg{queryId: allocateQueryId(), rows: rows, entries: entries, searchErr: err, forceUpdateTable: true, maintainCursor: false, overriddenSearchQuery: &emptyQuery, isFirstQuery: true})
|
|
}
|
|
}()
|
|
// 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 (note that we don't use the escaped one here)
|
|
SELECTED_COMMAND = strings.Join(initialQueryArray, " ")
|
|
}
|
|
fmt.Printf("%s\n", SELECTED_COMMAND)
|
|
return nil
|
|
}
|
|
|
|
// TODO: support custom key bindings
|
|
// TODO: make the help page wrap
|