garm/cmd/garm-cli/editor/editor.go
Gabriel 23f92bc335
Add runner install template management (#525)
* Add template api endpoints

Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com>

* Added template bypass

Pools and scale sets will automatically migrate to the new template
system for runner install scripts. If a pool or a scale set cannot be
migrate, it is left alone. It is expected that users set a runner install
template manually for scenarios we don't yet have a template for (windows
on gitea for example).

Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com>

* Integrate templates with pool create/update

Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com>

* Add webapp integration with templates

Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com>

* Add unit tests

Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com>

* Populate all relevant context fields

Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com>

* Update dependencies

Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com>

* Fix lint

Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com>

* Validate uint

Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com>

* Add CLI template management

Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com>

* Some editor improvements and bugfixes

Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com>

* Fix scale set return values post create

Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com>

* Fix template websocket events filter

Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com>

---------

Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com>
2025-09-23 13:46:27 +03:00

783 lines
20 KiB
Go

package editor
import (
"fmt"
"strings"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
// VimMode represents the current vim editing mode
type VimMode int
const (
VimNormal VimMode = iota
VimInsert
VimSearch
)
// Editor represents a text editor that can be launched with initial content
type Editor struct {
app *tview.Application
pages *tview.Pages
editor *tview.TextArea
footer *tview.TextView
result string
saved bool
useVim bool
vimMode VimMode
waitingForSecondG bool
searchTerm string
searchInput *tview.InputField
}
// NewEditor creates a new editor instance
func NewEditor() *Editor {
e := &Editor{
app: tview.NewApplication(),
pages: tview.NewPages(),
useVim: false,
vimMode: VimNormal,
}
return e
}
// SetVimMode enables or disables vim modal editing
func (e *Editor) SetVimMode(enabled bool) {
e.useVim = enabled
if enabled {
e.vimMode = VimNormal
}
}
// toggleVimMode toggles vim mode on/off and updates the UI
func (e *Editor) toggleVimMode() {
e.useVim = !e.useVim
if e.useVim {
e.vimMode = VimNormal
}
e.updateTitle()
e.updateFooter()
}
// updateTitle updates the editor title to show current vim mode
func (e *Editor) updateTitle() {
if e.useVim {
var modeStr string
switch e.vimMode {
case VimNormal:
modeStr = "NORMAL"
case VimInsert:
modeStr = "INSERT"
case VimSearch:
modeStr = "SEARCH"
}
e.editor.SetTitle(fmt.Sprintf("Text Editor (VIM - %s)", modeStr))
} else {
e.editor.SetTitle("Text Editor")
}
}
// resetGCommand resets the gg command state
func (e *Editor) resetGCommand() {
e.waitingForSecondG = false
}
// updateFooter updates the footer text based on current mode
func (e *Editor) updateFooter() {
if e.footer == nil {
return
}
footerText := "[yellow]Ctrl+S[white]: Save & Exit | [yellow]Ctrl+Q[white]: Quit | [yellow]Alt+H[white]: Help | [yellow]Alt+V[white]: Toggle VIM"
if e.useVim {
switch e.vimMode {
case VimNormal:
footerText += " | [green]VIM NORMAL[white]: i/a/o for insert, h/j/k/l/arrows nav, G/gg, / search, n/N find next/prev, x del"
case VimInsert:
footerText += " | [green]VIM INSERT[white]: Esc for normal mode"
case VimSearch:
footerText += " | [green]VIM SEARCH[white]: Enter search term, Enter to find, Esc to cancel"
}
}
e.footer.SetText(footerText)
}
// handleVimInput manages vim modal input handling
func (e *Editor) handleVimInput(event *tcell.EventKey) *tcell.EventKey {
switch e.vimMode {
case VimNormal:
return e.handleVimNormalMode(event)
case VimInsert:
return e.handleVimInsertMode(event)
case VimSearch:
return e.handleVimSearchMode(event)
}
return event
}
// handleVimNormalMode handles input in vim normal mode
func (e *Editor) handleVimNormalMode(event *tcell.EventKey) *tcell.EventKey {
// Handle global commands first
if result := e.handleGlobalCommands(event); result != event {
return result
}
// Handle vim character-based commands
if result := e.handleVimCharCommands(event); result != event {
return result
}
// Handle key-based navigation
return e.handleKeyNavigation(event)
}
// handleGlobalCommands handles global commands available in all modes
func (e *Editor) handleGlobalCommands(event *tcell.EventKey) *tcell.EventKey {
switch {
case event.Key() == tcell.KeyCtrlS:
e.result = e.editor.GetText()
e.saved = true
e.app.Stop()
return nil
case event.Key() == tcell.KeyCtrlQ:
e.app.Stop()
return nil
case event.Rune() == 'h' && event.Modifiers()&tcell.ModAlt != 0:
e.pages.SwitchToPage("help")
return nil
case event.Key() == tcell.KeyCtrlUnderscore:
e.pages.SwitchToPage("help")
return nil
}
return event // Continue processing
}
// handleVimCharCommands handles vim character-based commands
func (e *Editor) handleVimCharCommands(event *tcell.EventKey) *tcell.EventKey {
// Handle mode switching commands
if result := e.handleModeSwitching(event); result != event {
return result
}
// Handle navigation commands
if result := e.handleCharNavigation(event); result != event {
return result
}
// Handle editing commands
return e.handleEditingCommands(event)
}
// handleModeSwitching handles commands that switch vim modes
func (e *Editor) handleModeSwitching(event *tcell.EventKey) *tcell.EventKey {
switch event.Rune() {
case 'i':
e.resetGCommand()
e.enterInsertMode()
return nil
case 'a':
e.resetGCommand()
e.enterInsertMode()
return tcell.NewEventKey(tcell.KeyRight, 0, tcell.ModNone)
case 'A':
e.resetGCommand()
e.enterInsertMode()
return tcell.NewEventKey(tcell.KeyEnd, 0, tcell.ModNone)
case 'I':
e.resetGCommand()
e.enterInsertMode()
return tcell.NewEventKey(tcell.KeyHome, 0, tcell.ModNone)
case 'o':
e.resetGCommand()
e.enterInsertMode()
e.insertNewLineBelow()
return nil
case 'O':
e.resetGCommand()
e.enterInsertMode()
e.insertNewLineAbove()
return nil
}
return event // Continue processing
}
// enterInsertMode switches to insert mode
func (e *Editor) enterInsertMode() {
e.vimMode = VimInsert
e.updateTitle()
e.updateFooter()
}
// exitInsertMode switches to normal mode
func (e *Editor) exitInsertMode() {
if e.vimMode != VimInsert {
return
}
e.vimMode = VimNormal
e.updateTitle()
e.updateFooter()
}
// handleCharNavigation handles character-based navigation commands
func (e *Editor) handleCharNavigation(event *tcell.EventKey) *tcell.EventKey {
switch event.Rune() {
case 'h':
e.resetGCommand()
return tcell.NewEventKey(tcell.KeyLeft, 0, tcell.ModNone)
case 'j':
e.resetGCommand()
return tcell.NewEventKey(tcell.KeyDown, 0, tcell.ModNone)
case 'k':
e.resetGCommand()
return tcell.NewEventKey(tcell.KeyUp, 0, tcell.ModNone)
case 'l':
e.resetGCommand()
return tcell.NewEventKey(tcell.KeyRight, 0, tcell.ModNone)
case '0':
e.resetGCommand()
return tcell.NewEventKey(tcell.KeyHome, 0, tcell.ModNone)
case '$':
e.resetGCommand()
return tcell.NewEventKey(tcell.KeyEnd, 0, tcell.ModNone)
case 'w':
e.resetGCommand()
return tcell.NewEventKey(tcell.KeyRight, 0, tcell.ModCtrl)
case 'b':
e.resetGCommand()
return tcell.NewEventKey(tcell.KeyLeft, 0, tcell.ModCtrl)
case 'G':
e.resetGCommand()
e.goToEnd()
return nil
case 'g':
return e.handleGCommand(event)
}
return event // Continue processing
}
// handleEditingCommands handles editing and search commands
func (e *Editor) handleEditingCommands(event *tcell.EventKey) *tcell.EventKey {
switch event.Rune() {
case 'x':
e.resetGCommand()
return tcell.NewEventKey(tcell.KeyDelete, 0, tcell.ModNone)
case 'X':
e.resetGCommand()
return tcell.NewEventKey(tcell.KeyBackspace, 0, tcell.ModNone)
case 'd':
e.resetGCommand()
return e.handleDeleteCommand(event)
case '/':
e.resetGCommand()
e.startSearch()
return nil
case 'n':
e.resetGCommand()
e.findNext()
return nil
case 'N':
e.resetGCommand()
e.findPrevious()
return nil
}
return event // Continue processing
}
// handleKeyNavigation handles key-based navigation (arrow keys, etc.)
func (e *Editor) handleKeyNavigation(event *tcell.EventKey) *tcell.EventKey {
switch event.Key() {
case tcell.KeyEscape:
e.resetGCommand()
return nil
case tcell.KeyLeft:
e.resetGCommand()
return tcell.NewEventKey(tcell.KeyLeft, 0, tcell.ModNone)
case tcell.KeyDown:
e.resetGCommand()
return tcell.NewEventKey(tcell.KeyDown, 0, tcell.ModNone)
case tcell.KeyUp:
e.resetGCommand()
return tcell.NewEventKey(tcell.KeyUp, 0, tcell.ModNone)
case tcell.KeyRight:
e.resetGCommand()
return tcell.NewEventKey(tcell.KeyRight, 0, tcell.ModNone)
case tcell.KeyHome:
e.resetGCommand()
return tcell.NewEventKey(tcell.KeyHome, 0, tcell.ModNone)
case tcell.KeyEnd:
e.resetGCommand()
return tcell.NewEventKey(tcell.KeyEnd, 0, tcell.ModNone)
case tcell.KeyPgUp:
e.resetGCommand()
return tcell.NewEventKey(tcell.KeyPgUp, 0, tcell.ModNone)
case tcell.KeyPgDn:
e.resetGCommand()
return tcell.NewEventKey(tcell.KeyPgDn, 0, tcell.ModNone)
}
// Block all other text input in normal mode
return nil
}
// handleVimInsertMode handles input in vim insert mode
func (e *Editor) handleVimInsertMode(event *tcell.EventKey) *tcell.EventKey {
switch event.Key() {
case tcell.KeyEscape:
e.exitInsertMode()
return nil
case tcell.KeyCtrlS:
e.result = e.editor.GetText()
e.saved = true
e.app.Stop()
return nil
case tcell.KeyCtrlQ:
e.app.Stop()
return nil
}
// Pass through all other keys in insert mode
return event
}
// Helper functions for vim operations
func (e *Editor) insertNewLineBelow() {
// Move to end of current line and insert newline
e.editor.InputHandler()(tcell.NewEventKey(tcell.KeyEnd, 0, tcell.ModNone), nil)
e.editor.InputHandler()(tcell.NewEventKey(tcell.KeyEnter, 0, tcell.ModNone), nil)
}
func (e *Editor) insertNewLineAbove() {
// Move to beginning of line, insert newline, move up
e.editor.InputHandler()(tcell.NewEventKey(tcell.KeyHome, 0, tcell.ModNone), nil)
e.editor.InputHandler()(tcell.NewEventKey(tcell.KeyEnter, 0, tcell.ModNone), nil)
e.editor.InputHandler()(tcell.NewEventKey(tcell.KeyUp, 0, tcell.ModNone), nil)
}
func (e *Editor) goToEnd() {
// Go to end of document by setting text with cursor at end
text := e.editor.GetText()
e.editor.SetText(text, true) // true = cursor at end
}
func (e *Editor) goToBeginning() {
// Go to beginning of document by setting text with cursor at beginning
text := e.editor.GetText()
e.editor.SetText(text, false) // false = cursor at beginning
}
func (e *Editor) handleGCommand(_ *tcell.EventKey) *tcell.EventKey {
// For now, let's implement a simpler approach
// In vim, single 'g' usually requires a second command
// But let's make gg work by tracking the state in the editor instance
if e.waitingForSecondG {
// This is the second 'g' - execute gg (go to beginning)
e.waitingForSecondG = false
e.goToBeginning()
return nil
}
// First 'g' press - wait for second one
e.waitingForSecondG = true
return nil
}
func (e *Editor) handleDeleteCommand(_ *tcell.EventKey) *tcell.EventKey {
// For now, just implement x (delete character)
// A full implementation would handle dd, dw, etc.
return tcell.NewEventKey(tcell.KeyDelete, 0, tcell.ModNone)
}
// handleVimSearchMode handles input in vim search mode
func (e *Editor) handleVimSearchMode(event *tcell.EventKey) *tcell.EventKey {
// Search mode is handled by the modal dialog, so this shouldn't be called
// But keep it for consistency
switch event.Key() {
case tcell.KeyEscape:
e.cancelSearch()
return nil
case tcell.KeyCtrlS:
e.result = e.editor.GetText()
e.saved = true
e.app.Stop()
return nil
case tcell.KeyCtrlQ:
e.app.Stop()
return nil
}
return event
}
// startSearch enters search mode and shows search input
func (e *Editor) startSearch() {
e.vimMode = VimSearch
e.updateTitle()
e.updateFooter()
e.showSearchInput()
}
// cancelSearch exits search mode without searching
func (e *Editor) cancelSearch() {
e.vimMode = VimNormal
e.updateTitle()
e.updateFooter()
e.hideSearchInput()
}
// findNext finds the next occurrence of the search term
func (e *Editor) findNext() {
if e.searchTerm == "" {
return
}
text := e.editor.GetText()
fromRow, fromCol, _, _ := e.editor.GetCursor()
// Convert current position to linear position
lines := strings.Split(text, "\n")
currentPos := 0
for i := 0; i < fromRow && i < len(lines); i++ {
currentPos += len(lines[i]) + 1 // +1 for newline
}
currentPos += fromCol
// Search from current position + 1
searchFrom := currentPos + 1
if searchFrom < len(text) {
index := strings.Index(text[searchFrom:], e.searchTerm)
if index != -1 {
e.goToPosition(searchFrom + index)
return
}
}
// Wrap around search from beginning
index := strings.Index(text, e.searchTerm)
if index != -1 && index < currentPos {
e.goToPosition(index)
}
}
// findPrevious finds the previous occurrence of the search term
func (e *Editor) findPrevious() {
if e.searchTerm == "" {
return
}
text := e.editor.GetText()
fromRow, fromCol, _, _ := e.editor.GetCursor()
// Convert current position to linear position
lines := strings.Split(text, "\n")
currentPos := 0
for i := 0; i < fromRow && i < len(lines); i++ {
currentPos += len(lines[i]) + 1 // +1 for newline
}
currentPos += fromCol
// Search backwards from current position
if currentPos > 0 {
index := strings.LastIndex(text[:currentPos], e.searchTerm)
if index != -1 {
e.goToPosition(index)
return
}
}
// Wrap around search from end
index := strings.LastIndex(text, e.searchTerm)
if index != -1 && index > currentPos {
e.goToPosition(index)
}
}
// goToPosition moves cursor to a specific character position in the text
func (e *Editor) goToPosition(pos int) {
text := e.editor.GetText()
if pos >= len(text) {
return
}
// Simple approach: reset text with cursor at beginning, then move right
e.editor.SetText(text, false)
// Move cursor to the right position by simulating key presses
for range pos {
e.editor.InputHandler()(tcell.NewEventKey(tcell.KeyRight, 0, tcell.ModNone), nil)
}
}
// showSearchInput displays the search input field
func (e *Editor) showSearchInput() {
// Create search input field with proper handling
e.searchInput = tview.NewInputField().
SetLabel("Search: ").
SetFieldWidth(30).
SetText("")
// Set input capture for the search input
e.searchInput.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEnter {
// Get search term and perform search
e.searchTerm = e.searchInput.GetText()
if e.searchTerm != "" {
e.findNext()
}
e.vimMode = VimNormal
e.updateTitle()
e.updateFooter()
e.pages.RemovePage("search")
e.pages.SwitchToPage("main")
e.app.SetFocus(e.editor)
return nil
} else if event.Key() == tcell.KeyEscape {
// Cancel search
e.vimMode = VimNormal
e.updateTitle()
e.updateFooter()
e.pages.RemovePage("search")
e.pages.SwitchToPage("main")
e.app.SetFocus(e.editor)
return nil
}
return event
})
// Create a container with border and title
flex := tview.NewFlex().
SetDirection(tview.FlexRow).
AddItem(nil, 0, 1, false).
AddItem(e.searchInput, 1, 1, true).
AddItem(nil, 0, 1, false)
container := tview.NewFlex().
AddItem(nil, 0, 1, false).
AddItem(flex, 40, 1, true).
AddItem(nil, 0, 1, false)
// Create bordered frame
frame := tview.NewFrame(container).
SetBorders(1, 1, 1, 1, 2, 2).
AddText("Search", true, tview.AlignCenter, tcell.ColorWhite)
// Add to pages and switch
e.pages.AddPage("search", frame, true, true)
e.app.SetFocus(e.searchInput)
}
// hideSearchInput hides the search input field
func (e *Editor) hideSearchInput() {
e.pages.RemovePage("search")
e.pages.SwitchToPage("main")
e.app.SetFocus(e.editor)
}
// EditText launches the editor with initial content and returns the edited text
func (e *Editor) EditText(initialContent string) (string, bool, error) {
e.result = initialContent
e.saved = false
e.editor = tview.NewTextArea().
SetText(initialContent, false).
SetWrap(false)
e.editor.SetBorder(true).
SetTitleAlign(tview.AlignCenter)
e.updateTitle()
e.editor.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
// Handle global commands first (available in all modes)
switch {
case event.Key() == tcell.KeyCtrlS:
e.result = e.editor.GetText()
e.saved = true
e.app.Stop()
return nil
case event.Key() == tcell.KeyCtrlQ:
e.app.Stop()
return nil
case event.Rune() == 'h' && event.Modifiers()&tcell.ModAlt != 0:
e.pages.SwitchToPage("help")
return nil
case event.Key() == tcell.KeyCtrlUnderscore:
e.pages.SwitchToPage("help")
return nil
case event.Rune() == 'v' && event.Modifiers()&tcell.ModAlt != 0:
// Toggle vim mode
e.toggleVimMode()
return nil
}
// Handle vim keybindings if enabled
if e.useVim {
return e.handleVimInput(event)
}
return event
})
// Create footer with shortcuts
e.footer = tview.NewTextView().
SetDynamicColors(true).
SetTextAlign(tview.AlignCenter)
e.updateFooter()
// Create layout with editor and footer
grid := tview.NewGrid().
SetRows(0, 1).
AddItem(e.editor, 0, 0, 1, 1, 0, 0, true).
AddItem(e.footer, 1, 0, 1, 1, 0, 0, false)
// Create help pages
e.createHelpPages()
// Add main editor page
e.pages.AddPage("main", grid, true, true)
e.app.SetRoot(e.pages, true)
// Run the editor
err := e.app.Run()
if err != nil {
return "", false, err
}
return e.result, e.saved, nil
}
// createHelpPages creates the help system with multiple pages
func (e *Editor) createHelpPages() {
// Create three separate help pages
help1 := tview.NewTextView()
help1.SetDynamicColors(true)
navText := `[green]Navigation[white]
[yellow]Arrow Keys[white]: Move cursor around
[yellow]Ctrl-A, Home[white]: Move to beginning of line
[yellow]Ctrl-E, End[white]: Move to end of line
[yellow]Ctrl-F, Page Down[white]: Move down by one page
[yellow]Ctrl-B, Page Up[white]: Move up by one page
[yellow]Alt-Up[white]: Scroll page up
[yellow]Alt-Down[white]: Scroll page down
[yellow]Alt-Left[white]: Scroll page left
[yellow]Alt-Right[white]: Scroll page right
[yellow]Alt-B, Ctrl-Left[white]: Move back by one word
[yellow]Alt-F, Ctrl-Right[white]: Move forward by one word`
if e.useVim {
navText += `
[green]VIM Navigation (Normal Mode)[white]
[yellow]h/j/k/l or Arrow Keys[white]: Move left/down/up/right
[yellow]0/$ or Home/End[white]: Beginning/end of line
[yellow]w/b[white]: Word forward/backward
[yellow]gg/G[white]: Beginning/end of document
[yellow]Page Up/Down[white]: Page navigation
[green]VIM Mode Switching[white]
[yellow]i[white]: Insert mode at cursor
[yellow]a[white]: Insert mode after cursor
[yellow]A[white]: Insert mode at end of line
[yellow]I[white]: Insert mode at beginning of line
[yellow]o[white]: New line below + insert mode
[yellow]O[white]: New line above + insert mode
[green]VIM Editing[white]
[yellow]x/X[white]: Delete character right/left
[yellow]Esc[white]: Return to normal mode
[yellow]Alt+V[white]: Toggle VIM mode on/off
[green]VIM Search[white]
[yellow]/[white]: Search for text (opens dialog)
[yellow]n[white]: Find next occurrence
[yellow]N[white]: Find previous occurrence`
}
navText += `
[blue]Press Enter for more help, Escape to return to editor[white]`
help1.SetText(navText)
help1.SetBorder(true)
help1.SetTitle("Help - Navigation")
help2 := tview.NewTextView()
help2.SetDynamicColors(true)
help2.SetText(`[green]Editing[white]
Type to enter text.
[yellow]Ctrl-H, Backspace[white]: Delete left character
[yellow]Ctrl-D, Delete[white]: Delete right character
[yellow]Ctrl-K[white]: Delete to end of line
[yellow]Ctrl-W[white]: Delete rest of word
[yellow]Ctrl-U[white]: Delete current line
[green]Selection & Clipboard[white]
Hold [yellow]Shift[white] + movement keys to select
Double-click to select a word
[yellow]Ctrl-L[white]: Select entire text
[yellow]Ctrl-Q[white]: Copy selection
[yellow]Ctrl-X[white]: Cut selection
[yellow]Ctrl-V[white]: Paste
[blue]Press Enter for more help, Escape to return to editor[white]`)
help2.SetBorder(true)
help2.SetTitle("Help - Editing")
help3 := tview.NewTextView()
help3.SetDynamicColors(true)
help3.SetText(`[green]Editor Commands[white]
[yellow]Ctrl-S[white]: Save changes and exit editor
[yellow]Ctrl-Q[white]: Quit without saving changes
[yellow]Alt+H[white]: Show this help
[yellow]Alt+V[white]: Toggle VIM mode on/off
[green]Mouse Support[white]
Click to position cursor
Drag to select text
Double-click to select word
Mouse wheel to scroll
[blue]Press Enter to cycle back, Escape to return to editor[white]`)
help3.SetBorder(true)
help3.SetTitle("Help - Commands")
helpPages := tview.NewPages()
helpPages.AddPage("help1", help1, true, true)
helpPages.AddPage("help2", help2, true, false)
helpPages.AddPage("help3", help3, true, false)
currentHelpPage := 0
helpPageNames := []string{"help1", "help2", "help3"}
// Set input capture for all help pages
for _, helpView := range []*tview.TextView{help1, help2, help3} {
helpView.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEscape {
e.pages.SwitchToPage("main")
return nil
} else if event.Key() == tcell.KeyEnter {
currentHelpPage = (currentHelpPage + 1) % len(helpPageNames)
helpPages.SwitchToPage(helpPageNames[currentHelpPage])
return nil
}
return event
})
}
// Center the help dialog
helpGrid := tview.NewGrid().
SetColumns(0, 80, 0).
SetRows(0, 25, 0).
AddItem(helpPages, 1, 1, 1, 1, 0, 0, true)
e.pages.AddPage("help", helpGrid, true, false)
}