2022-10-16 18:22:34 +02:00
|
|
|
package lib
|
|
|
|
|
|
|
|
import (
|
|
|
|
"context"
|
|
|
|
"fmt"
|
2022-10-16 21:43:16 +02:00
|
|
|
"os"
|
|
|
|
"strings"
|
2022-10-16 18:22:34 +02:00
|
|
|
|
|
|
|
_ "embed" // for embedding config.sh
|
|
|
|
|
2022-10-24 00:21:59 +02:00
|
|
|
"github.com/charmbracelet/bubbles/key"
|
2022-10-16 18:22:34 +02:00
|
|
|
"github.com/charmbracelet/bubbles/spinner"
|
|
|
|
"github.com/charmbracelet/bubbles/table"
|
|
|
|
"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"
|
2022-10-16 21:43:16 +02:00
|
|
|
"github.com/muesli/termenv"
|
2022-10-16 18:22:34 +02:00
|
|
|
)
|
|
|
|
|
2022-10-16 21:43:16 +02:00
|
|
|
const TABLE_HEIGHT = 20
|
|
|
|
const PADDED_NUM_ENTRIES = TABLE_HEIGHT * 3
|
|
|
|
|
|
|
|
var selectedRow string = ""
|
|
|
|
|
2022-10-16 18:22:34 +02:00
|
|
|
var baseStyle = lipgloss.NewStyle().
|
|
|
|
BorderStyle(lipgloss.NormalBorder()).
|
|
|
|
BorderForeground(lipgloss.Color("240"))
|
|
|
|
|
|
|
|
type errMsg error
|
|
|
|
|
|
|
|
type model struct {
|
2022-10-22 08:12:56 +02:00
|
|
|
// 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
|
|
|
|
|
|
|
|
// Whether the TUI is quitting.
|
|
|
|
quitting bool
|
|
|
|
|
|
|
|
// The table used for displaying search results.
|
|
|
|
table table.Model
|
|
|
|
// The number of entries in the table.
|
2022-10-16 21:55:10 +02:00
|
|
|
numEntries int
|
2022-10-22 08:12:56 +02:00
|
|
|
// Whether the user has hit enter to select an entry and the TUI is thus about to quit.
|
|
|
|
selected bool
|
|
|
|
|
|
|
|
// The search box for the query
|
|
|
|
queryInput textinput.Model
|
2022-10-22 08:29:49 +02:00
|
|
|
// The query to run. Reset to nil after it was run.
|
|
|
|
runQuery *string
|
2022-10-22 08:12:56 +02:00
|
|
|
// The previous query that was run.
|
|
|
|
lastQuery string
|
|
|
|
|
|
|
|
// Unrecoverable error.
|
|
|
|
err 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
|
2022-10-16 18:22:34 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
type doneDownloadingMsg struct{}
|
|
|
|
type offlineMsg struct{}
|
|
|
|
type bannerMsg struct {
|
|
|
|
banner string
|
|
|
|
}
|
|
|
|
|
2022-10-16 21:55:10 +02:00
|
|
|
func initialModel(ctx *context.Context, t table.Model, initialQuery string, numEntries int) model {
|
2022-10-16 18:22:34 +02:00
|
|
|
s := spinner.New()
|
|
|
|
s.Spinner = spinner.Dot
|
|
|
|
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
|
|
|
|
queryInput := textinput.New()
|
|
|
|
queryInput.Placeholder = "ls"
|
|
|
|
queryInput.Focus()
|
|
|
|
queryInput.CharLimit = 156
|
|
|
|
queryInput.Width = 50
|
2022-10-16 21:43:16 +02:00
|
|
|
if initialQuery != "" {
|
|
|
|
queryInput.SetValue(initialQuery)
|
|
|
|
}
|
2022-10-22 08:29:49 +02:00
|
|
|
return model{ctx: ctx, spinner: s, isLoading: true, table: t, runQuery: &initialQuery, queryInput: queryInput, numEntries: numEntries}
|
2022-10-16 18:22:34 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
func (m model) Init() tea.Cmd {
|
|
|
|
return m.spinner.Tick
|
|
|
|
}
|
|
|
|
|
2022-10-16 21:43:16 +02:00
|
|
|
func runQueryAndUpdateTable(m model) model {
|
2022-10-22 08:29:49 +02:00
|
|
|
if m.runQuery != nil && *m.runQuery != m.lastQuery {
|
|
|
|
rows, numEntries, err := getRows(m.ctx, *m.runQuery)
|
2022-10-16 18:22:34 +02:00
|
|
|
if err != nil {
|
2022-10-22 08:07:52 +02:00
|
|
|
m.searchErr = err
|
2022-10-16 21:43:16 +02:00
|
|
|
return m
|
2022-10-22 08:07:52 +02:00
|
|
|
} else {
|
|
|
|
m.searchErr = nil
|
2022-10-16 18:22:34 +02:00
|
|
|
}
|
2022-10-16 21:55:10 +02:00
|
|
|
m.numEntries = numEntries
|
2022-10-16 18:22:34 +02:00
|
|
|
m.table.SetRows(rows)
|
|
|
|
m.table.SetCursor(0)
|
2022-10-22 08:29:49 +02:00
|
|
|
m.lastQuery = *m.runQuery
|
|
|
|
m.runQuery = nil
|
2022-10-16 18:22:34 +02:00
|
|
|
}
|
2022-10-16 21:55:10 +02:00
|
|
|
if m.table.Cursor() >= m.numEntries {
|
|
|
|
// Ensure that we can't scroll past the end of the table
|
|
|
|
m.table.SetCursor(m.numEntries - 1)
|
|
|
|
}
|
2022-10-16 21:43:16 +02:00
|
|
|
return m
|
|
|
|
}
|
2022-10-16 18:22:34 +02:00
|
|
|
|
2022-10-16 21:43:16 +02:00
|
|
|
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
2022-10-16 18:22:34 +02:00
|
|
|
switch msg := msg.(type) {
|
|
|
|
case tea.KeyMsg:
|
|
|
|
switch msg.String() {
|
2022-10-22 07:58:51 +02:00
|
|
|
case "esc", "ctrl+c":
|
2022-10-16 18:22:34 +02:00
|
|
|
m.quitting = true
|
|
|
|
return m, tea.Quit
|
|
|
|
case "enter":
|
2022-10-16 21:55:10 +02:00
|
|
|
if m.numEntries != 0 {
|
|
|
|
m.selected = true
|
|
|
|
}
|
2022-10-16 18:22:34 +02:00
|
|
|
return m, tea.Quit
|
|
|
|
default:
|
|
|
|
t, cmd1 := m.table.Update(msg)
|
|
|
|
m.table = t
|
2022-10-24 00:21:59 +02:00
|
|
|
if strings.HasPrefix(msg.String(), "alt+") {
|
|
|
|
return m, tea.Batch(cmd1)
|
|
|
|
}
|
2022-10-16 18:22:34 +02:00
|
|
|
i, cmd2 := m.queryInput.Update(msg)
|
|
|
|
m.queryInput = i
|
2022-10-22 08:29:49 +02:00
|
|
|
searchQuery := m.queryInput.Value()
|
|
|
|
m.runQuery = &searchQuery
|
2022-10-16 21:43:16 +02:00
|
|
|
m = runQueryAndUpdateTable(m)
|
2022-10-16 18:22:34 +02:00
|
|
|
return m, tea.Batch(cmd1, cmd2)
|
|
|
|
}
|
|
|
|
case errMsg:
|
|
|
|
m.err = msg
|
|
|
|
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.err != nil {
|
|
|
|
return fmt.Sprintf("An unrecoverable error occured: %v\n", m.err)
|
|
|
|
}
|
|
|
|
if m.selected {
|
2022-10-16 21:43:16 +02:00
|
|
|
selectedRow = m.table.SelectedRow()[4]
|
|
|
|
return ""
|
|
|
|
}
|
|
|
|
loadingMessage := ""
|
|
|
|
if m.isLoading {
|
|
|
|
loadingMessage = fmt.Sprintf("%s Loading hishtory entries from other devices...", m.spinner.View())
|
2022-10-16 18:22:34 +02:00
|
|
|
}
|
2022-10-22 08:07:52 +02:00
|
|
|
warning := ""
|
2022-10-16 18:22:34 +02:00
|
|
|
if m.isOffline {
|
2022-10-22 08:07:52 +02:00
|
|
|
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)
|
2022-10-16 18:22:34 +02:00
|
|
|
}
|
2022-10-22 08:07:52 +02:00
|
|
|
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()))
|
2022-10-16 18:22:34 +02:00
|
|
|
}
|
|
|
|
|
2022-10-16 21:55:10 +02:00
|
|
|
func getRows(ctx *context.Context, query string) ([]table.Row, int, error) {
|
2022-10-16 18:22:34 +02:00
|
|
|
db := hctx.GetDb(ctx)
|
2022-10-16 21:43:16 +02:00
|
|
|
data, err := data.Search(db, query, PADDED_NUM_ENTRIES)
|
2022-10-16 18:22:34 +02:00
|
|
|
if err != nil {
|
2022-10-16 21:55:10 +02:00
|
|
|
return nil, 0, err
|
2022-10-16 18:22:34 +02:00
|
|
|
}
|
|
|
|
var rows []table.Row
|
2022-10-16 21:43:16 +02:00
|
|
|
for i := 0; i < PADDED_NUM_ENTRIES; i++ {
|
|
|
|
if i < len(data) {
|
|
|
|
entry := data[i]
|
|
|
|
entry.Command = strings.ReplaceAll(entry.Command, "\n", " ") // TODO: handle multi-line commands better here
|
|
|
|
row := table.Row{entry.Hostname, entry.CurrentWorkingDirectory, entry.StartTime.Format("Jan 2 2006 15:04:05 MST"), fmt.Sprintf("%d", entry.ExitCode), entry.Command}
|
|
|
|
rows = append(rows, row)
|
|
|
|
} else {
|
|
|
|
rows = append(rows, table.Row{})
|
|
|
|
}
|
2022-10-16 18:22:34 +02:00
|
|
|
}
|
2022-10-16 21:55:10 +02:00
|
|
|
return rows, len(data), nil
|
2022-10-16 18:22:34 +02:00
|
|
|
}
|
|
|
|
|
2022-10-16 21:43:16 +02:00
|
|
|
func TuiQuery(ctx *context.Context, initialQuery string) error {
|
|
|
|
lipgloss.SetColorProfile(termenv.ANSI)
|
2022-10-16 18:22:34 +02:00
|
|
|
columns := []table.Column{
|
|
|
|
{Title: "Hostname", Width: 25},
|
|
|
|
{Title: "CWD", Width: 40},
|
|
|
|
{Title: "Timestamp", Width: 25},
|
|
|
|
{Title: "Exit Code", Width: 9},
|
|
|
|
{Title: "Command", Width: 70},
|
|
|
|
}
|
2022-10-16 21:55:10 +02:00
|
|
|
rows, numEntries, err := getRows(ctx, initialQuery)
|
2022-10-16 18:22:34 +02:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
2022-10-24 00:21:59 +02:00
|
|
|
km := table.KeyMap{
|
|
|
|
LineUp: key.NewBinding(
|
|
|
|
key.WithKeys("up", "alt+OA"),
|
|
|
|
key.WithHelp("↑", "scroll up"),
|
|
|
|
),
|
|
|
|
LineDown: key.NewBinding(
|
|
|
|
key.WithKeys("down", "alt+OB"),
|
|
|
|
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"),
|
|
|
|
),
|
|
|
|
GotoTop: key.NewBinding(
|
|
|
|
key.WithKeys("home"),
|
|
|
|
key.WithHelp("home", "go to start"),
|
|
|
|
),
|
|
|
|
GotoBottom: key.NewBinding(
|
|
|
|
key.WithKeys("end"),
|
|
|
|
key.WithHelp("end", "go to end"),
|
|
|
|
),
|
|
|
|
}
|
2022-10-16 18:22:34 +02:00
|
|
|
t := table.New(
|
|
|
|
table.WithColumns(columns),
|
2022-10-22 08:07:52 +02:00
|
|
|
table.WithRows(rows),
|
2022-10-16 18:22:34 +02:00
|
|
|
table.WithFocused(true),
|
2022-10-16 21:43:16 +02:00
|
|
|
table.WithHeight(TABLE_HEIGHT),
|
2022-10-24 00:21:59 +02:00
|
|
|
table.WithKeyMap(km),
|
2022-10-16 18:22:34 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
s := table.DefaultStyles()
|
|
|
|
s.Header = s.Header.
|
|
|
|
BorderStyle(lipgloss.NormalBorder()).
|
|
|
|
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()
|
|
|
|
|
2022-10-16 21:55:10 +02:00
|
|
|
p := tea.NewProgram(initialModel(ctx, t, initialQuery, numEntries), tea.WithOutput(os.Stderr))
|
2022-10-16 18:22:34 +02:00
|
|
|
go func() {
|
|
|
|
err := RetrieveAdditionalEntriesFromRemote(ctx)
|
|
|
|
if err != nil {
|
|
|
|
p.Send(err)
|
|
|
|
}
|
|
|
|
p.Send(doneDownloadingMsg{})
|
|
|
|
}()
|
|
|
|
go func() {
|
|
|
|
banner, err := GetBanner(ctx, "TODO_WIRE_GIT_COMMIT_HERE")
|
|
|
|
if err != nil {
|
|
|
|
if IsOfflineError(err) {
|
|
|
|
p.Send(offlineMsg{})
|
|
|
|
} else {
|
|
|
|
p.Send(err)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
p.Send(bannerMsg{banner: string(banner)})
|
|
|
|
}()
|
|
|
|
err = p.Start()
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
2022-10-16 21:43:16 +02:00
|
|
|
fmt.Printf("%s\n", selectedRow)
|
2022-10-16 18:22:34 +02:00
|
|
|
return nil
|
|
|
|
}
|