Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 97 additions & 16 deletions pkg/tui/dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ const (
)

const maxCommandOutputBytes = 1 << 20
const maxCommandHistoryEntries = 100
const commandHistoryBrowseNone = -1

type refreshTickMsg time.Time

Expand Down Expand Up @@ -189,6 +191,9 @@ type dashboardModel struct {
commandRunID int
commandCancel context.CancelFunc
commandOutput bool
commandHistory []string
commandHistoryAt int
commandDraft string
}

type networkSample struct {
Expand Down Expand Up @@ -222,20 +227,21 @@ func newDashboardModel(cfg *config.Config, plugins []plugin.InstalledPlugin) *da
keys := defaultKeyMap()

m := &dashboardModel{
cfg: cfg,
sites: groupContexts(cfg),
plugins: plugins,
tourPanes: loadTourPanes(),
currentContext: current,
width: 120,
height: 36,
keys: keys,
help: help.New(),
spin: spinner.New(spinner.WithSpinner(spinner.MiniDot), spinner.WithStyle(spinnerStyle)),
historyCPU: map[string][]float64{},
historyMemory: map[string][]float64{},
historyNet: map[string][]float64{},
lastNetSample: map[string]networkSample{},
cfg: cfg,
sites: groupContexts(cfg),
plugins: plugins,
tourPanes: loadTourPanes(),
currentContext: current,
width: 120,
height: 36,
keys: keys,
help: help.New(),
spin: spinner.New(spinner.WithSpinner(spinner.MiniDot), spinner.WithStyle(spinnerStyle)),
historyCPU: map[string][]float64{},
historyMemory: map[string][]float64{},
historyNet: map[string][]float64{},
lastNetSample: map[string]networkSample{},
commandHistoryAt: commandHistoryBrowseNone,
}
m.help.Styles = helpStyles()
m.siteIndex, m.envIndex = defaultSelection(m.sites, current)
Expand Down Expand Up @@ -369,6 +375,7 @@ func (m *dashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.commandCancel = nil
m.screen = screenLogs
m.logsTitle = "Command Output"
m.logTarget = ""
content := msg.Output
if msg.Err != nil {
if strings.TrimSpace(content) == "" {
Expand Down Expand Up @@ -401,6 +408,7 @@ func (m *dashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.lastMessage = fmt.Sprintf("Failed to reload sitectl state: %v", msg.Err)
return m, nil
}
preserveCommandOutput := m.screen == screenLogs && m.logsTitle == "Command Output"
m.cfg = msg.Config
m.sites = groupContexts(msg.Config)
m.plugins = msg.Plugins
Expand All @@ -411,8 +419,11 @@ func (m *dashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.loading = false
m.loadingLog = false
m.logsErr = nil
m.logsTitle = "Logs"
m.screen = screenDashboard
if !preserveCommandOutput {
m.logsTitle = "Logs"
m.logTarget = ""
m.screen = screenDashboard
}
m.chooser = newMenuModel(chooserTitle(m.sites), chooserItems(m.sites, m.plugins))
m.refreshCommandSuggestions()
m.syncLayout()
Expand Down Expand Up @@ -487,6 +498,7 @@ func (m *dashboardModel) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
}
if strings.TrimSpace(m.commandInput.Value()) != "" {
m.commandInput.SetValue("")
m.resetCommandHistoryNavigation()
m.commandQuitArmed = false
m.lastMessage = "Command cleared."
return m, nil
Expand All @@ -503,16 +515,28 @@ func (m *dashboardModel) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
return m, nil
case key.Matches(msg, m.keys.Back):
m.commandQuitArmed = false
m.resetCommandHistoryNavigation()
m.commandInput.Blur()
return m, nil
case key.Matches(msg, m.keys.Terminal):
m.commandQuitArmed = false
m.resetCommandHistoryNavigation()
return m.runCommand(true)
case msg.String() == "enter":
m.commandQuitArmed = false
m.resetCommandHistoryNavigation()
return m.runCommand(false)
case msg.String() == "up":
m.commandQuitArmed = false
m.previousCommandHistory()
return m, nil
case msg.String() == "down":
m.commandQuitArmed = false
m.nextCommandHistory()
return m, nil
default:
m.commandQuitArmed = false
m.resetCommandHistoryNavigation()
var cmd tea.Cmd
m.commandInput, cmd = m.commandInput.Update(msg)
return m, cmd
Expand Down Expand Up @@ -1309,6 +1333,7 @@ func (m *dashboardModel) runCommand(interactive bool) (tea.Model, tea.Cmd) {
m.lastMessage = err.Error()
return m, nil
}
m.addCommandHistory(raw)

if interactive || isInteractiveArgs(args) {
m.commandRunning = true
Expand All @@ -1327,13 +1352,69 @@ func (m *dashboardModel) runCommand(interactive bool) (tea.Model, tea.Cmd) {
runCtx, cancel := context.WithCancel(context.Background())
m.commandCancel = cancel
m.logsTitle = "Command Output"
m.logTarget = ""
m.logsBody = "Running " + streamDisplay + "..."
m.logs.SetContent(m.logsBody)
m.screen = screenLogs
m.commandInput.SetValue("")
return m, runSitectlStreamCmd(runCtx, runID, streamDisplay, streamArgs)
}

func (m *dashboardModel) addCommandHistory(raw string) {
raw = strings.TrimSpace(raw)
if raw == "" {
return
}
if len(m.commandHistory) > 0 && m.commandHistory[len(m.commandHistory)-1] == raw {
m.resetCommandHistoryNavigation()
return
}
m.commandHistory = append(m.commandHistory, raw)
if len(m.commandHistory) > maxCommandHistoryEntries {
m.commandHistory = m.commandHistory[len(m.commandHistory)-maxCommandHistoryEntries:]
}
m.resetCommandHistoryNavigation()
}

func (m *dashboardModel) previousCommandHistory() bool {
if len(m.commandHistory) == 0 {
return false
}
if m.commandHistoryAt == commandHistoryBrowseNone {
m.commandDraft = m.commandInput.Value()
m.commandHistoryAt = len(m.commandHistory) - 1
} else if m.commandHistoryAt > 0 {
m.commandHistoryAt--
}
m.setCommandInputValue(m.commandHistory[m.commandHistoryAt])
return true
}

func (m *dashboardModel) nextCommandHistory() bool {
if len(m.commandHistory) == 0 || m.commandHistoryAt == commandHistoryBrowseNone {
return false
}
if m.commandHistoryAt < len(m.commandHistory)-1 {
m.commandHistoryAt++
m.setCommandInputValue(m.commandHistory[m.commandHistoryAt])
return true
}
m.commandHistoryAt = commandHistoryBrowseNone
m.setCommandInputValue(m.commandDraft)
m.commandDraft = ""
return true
}

func (m *dashboardModel) resetCommandHistoryNavigation() {
m.commandHistoryAt = commandHistoryBrowseNone
m.commandDraft = ""
}

func (m *dashboardModel) setCommandInputValue(value string) {
m.commandInput.SetValue(value)
m.commandInput.SetCursor(len([]rune(value)))
}

func (m *dashboardModel) executeChooserAction(action string) (tea.Model, tea.Cmd) {
switch {
case action == "tour":
Expand Down
73 changes: 73 additions & 0 deletions pkg/tui/dashboard_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"strings"
"testing"

"charm.land/bubbles/v2/textinput"
"github.com/libops/sitectl/pkg/config"
"github.com/libops/sitectl/pkg/plugin"
)
Expand Down Expand Up @@ -176,6 +177,78 @@ func TestStreamSafeSitectlArgsAddsNoTTYForComposeExecRun(t *testing.T) {
}
}

func TestStateReloadPreservesCommandOutputPane(t *testing.T) {
cfg := &config.Config{
CurrentContext: "stage",
Contexts: []config.Context{{
Name: "stage",
Site: "site",
Environment: "stage",
}},
}
m := newDashboardModel(cfg, nil)
m.screen = screenLogs
m.logsTitle = "Command Output"
m.logsBody = "https://example.test/user/reset"
m.logs.SetContent(m.logsBody)
m.logTarget = ""

model, _ := m.Update(stateReloadedMsg{
Config: cfg,
CurrentContext: "stage",
})
got := model.(*dashboardModel)
if got.screen != screenLogs {
t.Fatalf("expected command output pane to stay open, got screen %v", got.screen)
}
if got.logsTitle != "Command Output" {
t.Fatalf("expected command output title to be preserved, got %q", got.logsTitle)
}
if got.logsBody != "https://example.test/user/reset" {
t.Fatalf("expected command output body to be preserved, got %q", got.logsBody)
}
}

func TestCommandHistoryNavigation(t *testing.T) {
m := &dashboardModel{commandHistoryAt: commandHistoryBrowseNone}
m.commandInput = textinput.New()
m.addCommandHistory("compose ps")
m.addCommandHistory("compose exec drupal drush uli")
m.addCommandHistory("compose exec drupal drush uli")
if len(m.commandHistory) != 2 {
t.Fatalf("expected consecutive duplicate command to be skipped, got %#v", m.commandHistory)
}

m.commandInput.SetValue("draft")
if !m.previousCommandHistory() {
t.Fatal("expected previous history entry")
}
if got := m.commandInput.Value(); got != "compose exec drupal drush uli" {
t.Fatalf("first previous command = %q", got)
}
if !m.previousCommandHistory() {
t.Fatal("expected older history entry")
}
if got := m.commandInput.Value(); got != "compose ps" {
t.Fatalf("second previous command = %q", got)
}
if !m.nextCommandHistory() {
t.Fatal("expected newer history entry")
}
if got := m.commandInput.Value(); got != "compose exec drupal drush uli" {
t.Fatalf("next command = %q", got)
}
if !m.nextCommandHistory() {
t.Fatal("expected draft restoration")
}
if got := m.commandInput.Value(); got != "draft" {
t.Fatalf("restored draft = %q", got)
}
if m.nextCommandHistory() {
t.Fatal("expected no next entry after restoring draft")
}
}

func TestTrimCommandOutputKeepsLatestOutput(t *testing.T) {
value := strings.Repeat("a", maxCommandOutputBytes) + "\nlatest"
got := trimCommandOutput(value)
Expand Down