mirror of
https://github.com/hanzoai/insights.git
synced 2026-10-30 16:15:31 +00:00
The tree was a debranded snapshot of upstream taken around 2026-02-20, and upstream has moved about five months since. This replaces it with upstream master, mechanically re-derived: bin/debrand does the rename, and the pipeline and squashed migrations are restored from the shipped tree. Not a merge. The previous import replaced the tree wholesale in a single commit, so there is no shared content history to merge against. Reproducing the rename on the new tree is what actually converges. What survives: .hanzo/workflows, the Dockerfiles and bin/docker* entrypoints, compose files, LLM.md, and insights/migrations. The squashed initial migration is load-bearing — the live database records it by name, so upstream's ~918 individual migrations cannot come back. Nine products are dropped as unreferenced. Twenty-one more that we do not run are kept anyway because the core app still imports them; unpicking those is a refactor and does not belong in a rebase. This base does not build yet, and the reasons are not in the rename: upstream now wants Python 3.13.13 against our 3.12.12 and Django 5.2 against our 4.2, and the dependency manifests still name upstream packages that we republish under our own names. Those are decisions, not renames.
670 lines
18 KiB
Go
670 lines
18 KiB
Go
package tui
|
|
|
|
import (
|
|
"fmt"
|
|
"os/exec"
|
|
"runtime"
|
|
"strings"
|
|
|
|
"charm.land/bubbles/v2/key"
|
|
tea "charm.land/bubbletea/v2"
|
|
|
|
"github.com/insights/insights/phrocs/internal/process"
|
|
)
|
|
|
|
// procViewerCmd returns an exec.Cmd for the best available process viewer,
|
|
// filtered to the given root PID where possible. Falls back to unfiltered
|
|
// viewers when PID filtering isn't supported. Returns nil if none found.
|
|
//
|
|
// Priority: htop (PID-filtered) > btop (unfiltered) > top (PID-filtered).
|
|
func procViewerCmd(pid int) *exec.Cmd {
|
|
pidStr := fmt.Sprintf("%d", pid)
|
|
|
|
// htop: tree view + PID filter on all platforms
|
|
if path, err := exec.LookPath("htop"); err == nil {
|
|
return exec.Command(path, "-t", "-p", pidStr)
|
|
}
|
|
|
|
// btop: no PID filter support
|
|
if path, err := exec.LookPath("btop"); err == nil {
|
|
return exec.Command(path)
|
|
}
|
|
|
|
// top: PID-filtered, syntax differs by OS
|
|
if path, err := exec.LookPath("top"); err == nil {
|
|
if runtime.GOOS == "darwin" {
|
|
return exec.Command(path, "-pid", pidStr)
|
|
}
|
|
// Linux top uses -p
|
|
return exec.Command(path, "-p", pidStr)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Resets all search state and clears viewport highlighting.
|
|
func (m *Model) clearSearch() {
|
|
m.searchQuery = ""
|
|
m.searchMatches = nil
|
|
m.searchCursor = 0
|
|
m.viewport.StyleLineFunc = nil
|
|
}
|
|
|
|
// Forwards a message to the viewport and tracks scroll position.
|
|
func (m *Model) forwardToViewport(msg tea.Msg) tea.Cmd {
|
|
var cmd tea.Cmd
|
|
m.viewport, cmd = m.viewport.Update(msg)
|
|
m.viewportAtBottom = m.viewport.AtBottom()
|
|
return cmd
|
|
}
|
|
|
|
// Handles viewport navigation keys (home/end/pgup/pgdn). Returns true if the
|
|
// key was consumed.
|
|
func (m *Model) handleViewportNavKey(msg tea.KeyPressMsg, cmds *[]tea.Cmd) bool {
|
|
switch {
|
|
case key.Matches(msg, m.keys.GotoTop):
|
|
m.dbg("viewport: home → goto top")
|
|
m.viewport.GotoTop()
|
|
m.viewportAtBottom = false
|
|
case key.Matches(msg, m.keys.GotoBottom):
|
|
m.dbg("viewport: end → goto bottom")
|
|
m.viewport.GotoBottom()
|
|
m.viewportAtBottom = true
|
|
case key.Matches(msg, m.keys.ScrollUp), key.Matches(msg, m.keys.ScrollDown):
|
|
*cmds = append(*cmds, m.forwardToViewport(msg))
|
|
default:
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// Cycles the focused pane forward (+1) or backward (-1).
|
|
func (m *Model) cyclePane(dir int) {
|
|
panes := []focusPane{focusServices, focusOutput}
|
|
if m.isDockerMode() {
|
|
panes = append(panes, focusContainers)
|
|
}
|
|
for i, p := range panes {
|
|
if p == m.focusedPane {
|
|
m.focusedPane = panes[(i+dir+len(panes))%len(panes)]
|
|
// Leaving the output pane drops explicit input mode.
|
|
if m.focusedPane != focusOutput {
|
|
m.inputMode = false
|
|
m.inputBuffer = ""
|
|
}
|
|
m.dbg("focus: → %d", m.focusedPane)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m Model) handleSearchKey(msg tea.KeyPressMsg, cmds []tea.Cmd) (Model, []tea.Cmd, bool) {
|
|
switch {
|
|
case msg.Code == tea.KeyEscape:
|
|
m.searchMode = false
|
|
m.clearSearch()
|
|
m = m.applySize()
|
|
case key.Matches(msg, m.keys.CommitFilter):
|
|
// Preserve query; drop search-only match state before switching to filter
|
|
m.searchMatches = nil
|
|
m.searchCursor = 0
|
|
m.viewport.StyleLineFunc = nil
|
|
m.searchMode = false
|
|
m.filterMode = true
|
|
m.recomputeFilter()
|
|
m = m.applySize()
|
|
case key.Matches(msg, m.keys.SearchNext):
|
|
if len(m.searchMatches) > 0 {
|
|
m.searchCursor = (m.searchCursor + 1) % len(m.searchMatches)
|
|
m.applySearchStyle()
|
|
m.jumpToCurrentMatch()
|
|
}
|
|
case key.Matches(msg, m.keys.SearchPrev):
|
|
if len(m.searchMatches) > 0 {
|
|
m.searchCursor = (m.searchCursor - 1 + len(m.searchMatches)) % len(m.searchMatches)
|
|
m.applySearchStyle()
|
|
m.jumpToCurrentMatch()
|
|
}
|
|
case key.Matches(msg, m.keys.Backspace):
|
|
if len(m.searchQuery) > 0 {
|
|
runes := []rune(m.searchQuery)
|
|
m.searchQuery = string(runes[:len(runes)-1])
|
|
m.recomputeSearch()
|
|
}
|
|
default:
|
|
if m.handleViewportNavKey(msg, &cmds) {
|
|
break
|
|
}
|
|
// Search consumes all printable characters for the query
|
|
s := msg.String()
|
|
var ch string
|
|
if s == "space" {
|
|
ch = " "
|
|
} else if runes := []rune(s); len(runes) == 1 && runes[0] >= 32 {
|
|
ch = s
|
|
}
|
|
if ch != "" {
|
|
m.searchQuery += ch
|
|
m.recomputeSearch()
|
|
}
|
|
}
|
|
return m, cmds, true
|
|
}
|
|
|
|
func (m Model) handleFilterKey(msg tea.KeyPressMsg, cmds []tea.Cmd) (Model, []tea.Cmd, bool) {
|
|
switch {
|
|
case msg.Code == tea.KeyEscape:
|
|
m.filterMode = false
|
|
m.searchQuery = ""
|
|
m.reloadActiveLines()
|
|
m = m.applySize()
|
|
if m.viewportAtBottom {
|
|
m.viewport.GotoBottom()
|
|
}
|
|
case key.Matches(msg, m.keys.ToggleFilter):
|
|
// Preserve query; restore unfiltered viewport and rebuild search match state
|
|
m.filterMode = false
|
|
m.reloadActiveLines()
|
|
m.searchMode = true
|
|
m.recomputeSearch()
|
|
m.jumpToCurrentMatch()
|
|
m = m.applySize()
|
|
case key.Matches(msg, m.keys.Backspace):
|
|
if len(m.searchQuery) > 0 {
|
|
runes := []rune(m.searchQuery)
|
|
m.searchQuery = string(runes[:len(runes)-1])
|
|
m.recomputeFilter()
|
|
} else {
|
|
// Backspace on empty query exits filter back to search
|
|
m.filterMode = false
|
|
m.reloadActiveLines()
|
|
m.searchMode = true
|
|
m.recomputeSearch()
|
|
m.jumpToCurrentMatch()
|
|
m = m.applySize()
|
|
}
|
|
default:
|
|
if m.handleViewportNavKey(msg, &cmds) {
|
|
break
|
|
}
|
|
s := msg.String()
|
|
var ch string
|
|
if s == "space" {
|
|
ch = " "
|
|
} else if runes := []rune(s); len(runes) == 1 && runes[0] >= 32 {
|
|
ch = s
|
|
}
|
|
if ch != "" {
|
|
m.searchQuery += ch
|
|
m.recomputeFilter()
|
|
}
|
|
}
|
|
return m, cmds, true
|
|
}
|
|
|
|
func (m Model) handleCopyKey(msg tea.KeyPressMsg, cmds []tea.Cmd) (Model, []tea.Cmd, bool) {
|
|
switch {
|
|
case key.Matches(msg, m.keys.Quit), msg.Code == tea.KeyEscape:
|
|
m.dbg("copy mode: exit")
|
|
m.copyMode = false
|
|
m.applyCopyStyle()
|
|
m = m.applySize()
|
|
|
|
case key.Matches(msg, m.keys.CopyMode):
|
|
if m.copyAnchor < 0 {
|
|
m.copyAnchor = m.copyCursor
|
|
m.dbg("copy mode: anchor set at line %d", m.copyAnchor)
|
|
} else if m.copyCursor != m.copyAnchor {
|
|
text := m.copySelectedText()
|
|
m.dbg("copy mode: copied %d lines", strings.Count(text, "\n")+1)
|
|
m.copyMode = false
|
|
m.applyCopyStyle()
|
|
m = m.applySize()
|
|
cmds = append(cmds, tea.SetClipboard(text))
|
|
} else {
|
|
m.copyAnchor = -1
|
|
m.dbg("copy mode: anchor cleared")
|
|
}
|
|
m.applyCopyStyle()
|
|
|
|
case key.Matches(msg, m.keys.NextProc), key.Matches(msg, m.keys.KeyDown):
|
|
if m.copyCursor < m.viewport.TotalLineCount()-1 {
|
|
m.copyCursor++
|
|
m.ensureCopyCursorVisible()
|
|
m.applyCopyStyle()
|
|
}
|
|
|
|
case key.Matches(msg, m.keys.PrevProc), key.Matches(msg, m.keys.KeyUp):
|
|
if m.copyCursor > 0 {
|
|
m.copyCursor--
|
|
m.ensureCopyCursorVisible()
|
|
m.applyCopyStyle()
|
|
}
|
|
|
|
case key.Matches(msg, m.keys.GotoTop):
|
|
m.copyCursor = 0
|
|
m.ensureCopyCursorVisible()
|
|
m.applyCopyStyle()
|
|
|
|
case key.Matches(msg, m.keys.GotoBottom):
|
|
if total := m.viewport.TotalLineCount(); total > 0 {
|
|
m.copyCursor = total - 1
|
|
}
|
|
m.ensureCopyCursorVisible()
|
|
m.applyCopyStyle()
|
|
|
|
default:
|
|
return m, cmds, false
|
|
}
|
|
return m, cmds, true
|
|
}
|
|
|
|
func (m Model) handleInfoKey(msg tea.KeyPressMsg, cmds []tea.Cmd) (Model, []tea.Cmd, bool) {
|
|
switch {
|
|
|
|
case key.Matches(msg, m.keys.InfoMode), msg.Code == tea.KeyEscape:
|
|
m.infoMode = false
|
|
m.disableAllMetrics()
|
|
if !m.isDockerMode() {
|
|
m.reloadActiveLines()
|
|
m.viewport.GotoBottom()
|
|
m.viewportAtBottom = true
|
|
}
|
|
m.dbg("info mode: exit")
|
|
|
|
default:
|
|
return m, cmds, false
|
|
}
|
|
return m, cmds, true
|
|
}
|
|
|
|
func (m Model) handleMascotKey(msg tea.KeyPressMsg, cmds []tea.Cmd) (Model, []tea.Cmd, bool) {
|
|
switch {
|
|
case key.Matches(msg, m.keys.Mascot), msg.Code == tea.KeyEscape:
|
|
m.mascotMode = false
|
|
m.dbg("mascot mode: exit")
|
|
|
|
case msg.Code == tea.KeySpace:
|
|
// Jump only when on the ground
|
|
if m.mascotY == 0 {
|
|
m.mascotVelY = 1
|
|
m.mascotY = 1
|
|
m.dbg("mascot: jump!")
|
|
}
|
|
|
|
default:
|
|
return m, cmds, false
|
|
}
|
|
return m, cmds, true
|
|
}
|
|
|
|
// updateProcKeys enables/disables start, stop, and restart bindings
|
|
// based on the active process state.
|
|
func (m *Model) updateProcKeys() {
|
|
// RestartAllFailed is global (operates across the whole sidebar) and is
|
|
// gated on at-least-one failed proc so the hotkey is a no-op when
|
|
// there's nothing to do.
|
|
m.keys.RestartAllFailed.SetEnabled(m.hasFailedProc())
|
|
|
|
p := m.activeProc()
|
|
if p == nil {
|
|
m.keys.Start.SetEnabled(false)
|
|
m.keys.Stop.SetEnabled(false)
|
|
m.keys.Restart.SetEnabled(false)
|
|
m.keys.ClearLogs.SetEnabled(false)
|
|
return
|
|
}
|
|
if p.IsStandby() {
|
|
m.keys.Start.SetEnabled(true)
|
|
m.keys.Stop.SetEnabled(false)
|
|
m.keys.Restart.SetEnabled(true)
|
|
m.keys.ClearLogs.SetEnabled(false)
|
|
return
|
|
}
|
|
running := p.Status().IsRunning()
|
|
m.keys.Start.SetEnabled(!running)
|
|
m.keys.Stop.SetEnabled(running)
|
|
// `r` always has work to do — restart a running proc, or (re)run one
|
|
// that crashed, finished, was stopped, or never started. One key, one
|
|
// intent: "run this thing", regardless of what state it's in.
|
|
m.keys.Restart.SetEnabled(true)
|
|
m.keys.ClearLogs.SetEnabled(running)
|
|
}
|
|
|
|
// hasFailedProc reports whether any sidebar service is currently in
|
|
// StatusCrashed — i.e. whether `R` has anything to do.
|
|
func (m *Model) hasFailedProc() bool {
|
|
for _, p := range m.services {
|
|
if p.Status() == process.StatusCrashed {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// restartAllFailed starts every service currently in StatusCrashed in
|
|
// display order. Manually-stopped, clean-exit, never-started, and standby
|
|
// procs are all in different status states and naturally skipped, so the
|
|
// hotkey only ever touches procs that actually died on their own.
|
|
// Returns the number of procs that were kicked off.
|
|
func (m *Model) restartAllFailed() int {
|
|
send := m.mgr.Send()
|
|
count := 0
|
|
for _, p := range m.services {
|
|
if p.Status() != process.StatusCrashed {
|
|
continue
|
|
}
|
|
m.dbg("restart failed: proc=%s", p.Name)
|
|
// Fan out so we don't block the TUI event loop while Start allocates
|
|
// a PTY and forks the child. As a side benefit, spawns happen in
|
|
// parallel rather than serially. Capture p locally to avoid the
|
|
// closure-over-loop-variable pitfall on older Go versions.
|
|
proc := p
|
|
go func() { _ = proc.Start(send) }()
|
|
count++
|
|
}
|
|
return count
|
|
}
|
|
|
|
// isForwardingInput reports whether keystrokes should go to the active proc's
|
|
// PTY rather than the TUI. True when the output pane is focused, the proc is
|
|
// running, and either the user explicitly entered input mode or the proc looks
|
|
// like it's waiting for input (HasPrompt heuristic).
|
|
func (m Model) isForwardingInput() bool {
|
|
p := m.activeProc()
|
|
return p != nil && m.focusedPane == focusOutput && p.IsRunning() &&
|
|
(m.inputMode || p.HasPrompt())
|
|
}
|
|
|
|
func (m Model) handleNormalKey(msg tea.KeyPressMsg, cmds []tea.Cmd) (tea.Model, tea.Cmd) {
|
|
// When the active process is waiting for input, buffer keystrokes and send them on Enter.
|
|
p := m.activeProc()
|
|
// Control keys are excluded so navigation (pane switching, scrolling) still
|
|
// works even while keystrokes are being forwarded to the proc.
|
|
isControlKey := key.Matches(msg, m.keys.NextPane) ||
|
|
key.Matches(msg, m.keys.PrevPane) ||
|
|
key.Matches(msg, m.keys.GotoTop) ||
|
|
key.Matches(msg, m.keys.GotoBottom) ||
|
|
key.Matches(msg, m.keys.ScrollDown) ||
|
|
key.Matches(msg, m.keys.ScrollUp)
|
|
|
|
if m.isForwardingInput() && !isControlKey {
|
|
// esc always leaves explicit input mode without touching the proc.
|
|
if m.inputMode && msg.Code == tea.KeyEscape {
|
|
m.inputMode = false
|
|
m.inputBuffer = ""
|
|
m.dbg("input mode: exit")
|
|
return m, tea.Batch(cmds...)
|
|
}
|
|
|
|
var input []byte
|
|
|
|
switch msg.Code {
|
|
case tea.KeyEnter:
|
|
input = []byte(m.inputBuffer + "\r")
|
|
m.inputBuffer = ""
|
|
case tea.KeyBackspace:
|
|
if len(m.inputBuffer) > 0 {
|
|
runes := []rune(m.inputBuffer)
|
|
m.inputBuffer = string(runes[:len(runes)-1])
|
|
}
|
|
case tea.KeySpace:
|
|
m.inputBuffer += " "
|
|
case tea.KeyDown:
|
|
input = []byte("\x1b[B")
|
|
case tea.KeyUp:
|
|
input = []byte("\x1b[A")
|
|
case tea.KeyRight:
|
|
input = []byte("\x1b[C")
|
|
case tea.KeyLeft:
|
|
input = []byte("\x1b[D")
|
|
case tea.KeyTab:
|
|
input = []byte("\t")
|
|
case tea.KeyDelete:
|
|
m.inputBuffer = ""
|
|
default:
|
|
s := msg.String()
|
|
if runes := []rune(s); len(runes) == 1 && runes[0] >= 32 {
|
|
m.inputBuffer += s
|
|
}
|
|
}
|
|
|
|
if input != nil {
|
|
if err := p.WriteInput(input); err != nil {
|
|
m.dbg("pty write error: %v", err)
|
|
} else {
|
|
m.dbg("pty send: %q", input)
|
|
}
|
|
}
|
|
return m, tea.Batch(cmds...)
|
|
}
|
|
|
|
switch {
|
|
case key.Matches(msg, m.keys.Quit):
|
|
m.mgr.StopAll()
|
|
return m, tea.Quit
|
|
|
|
case key.Matches(msg, m.keys.Help):
|
|
m.showHelp = !m.showHelp
|
|
m.dbg("help toggled: showHelp=%v", m.showHelp)
|
|
m = m.applySize()
|
|
|
|
case key.Matches(msg, m.keys.NextPane):
|
|
m.cyclePane(+1)
|
|
|
|
case key.Matches(msg, m.keys.PrevPane):
|
|
m.cyclePane(-1)
|
|
|
|
case key.Matches(msg, m.keys.NextProc), key.Matches(msg, m.keys.KeyDown):
|
|
if m.focusedPane == focusServices {
|
|
moved := false
|
|
if m.isGrouped() {
|
|
moved = m.nextProcEntry()
|
|
} else if m.servicesCursor < len(m.services)-1 {
|
|
m.servicesCursor++
|
|
moved = true
|
|
}
|
|
if moved {
|
|
m.ensureSidebarCursorVisible()
|
|
m.updateProcKeys()
|
|
m.dbg("proc selected: %s", m.services[m.servicesCursor].Name)
|
|
var loadCmds []tea.Cmd
|
|
m, loadCmds = m.loadActiveProc()
|
|
cmds = append(cmds, loadCmds...)
|
|
}
|
|
} else if m.focusedPane == focusContainers && m.isDockerMode() {
|
|
if m.containerCursor < m.containerEntryCount()-1 {
|
|
m.containerCursor++
|
|
m.ensureContainerCursorVisible()
|
|
m.dbg("container selected: %d", m.containerCursor)
|
|
m = m.loadContainerView()
|
|
}
|
|
} else {
|
|
cmds = append(cmds, m.forwardToViewport(msg))
|
|
}
|
|
|
|
case key.Matches(msg, m.keys.PrevProc), key.Matches(msg, m.keys.KeyUp):
|
|
if m.focusedPane == focusServices {
|
|
moved := false
|
|
if m.isGrouped() {
|
|
moved = m.prevProcEntry()
|
|
} else if m.servicesCursor > 0 {
|
|
m.servicesCursor--
|
|
moved = true
|
|
}
|
|
if moved {
|
|
m.ensureSidebarCursorVisible()
|
|
m.updateProcKeys()
|
|
m.dbg("proc selected: %s", m.services[m.servicesCursor].Name)
|
|
var loadCmds []tea.Cmd
|
|
m, loadCmds = m.loadActiveProc()
|
|
cmds = append(cmds, loadCmds...)
|
|
}
|
|
} else if m.focusedPane == focusContainers && m.isDockerMode() {
|
|
if m.containerCursor > 0 {
|
|
m.containerCursor--
|
|
m.ensureContainerCursorVisible()
|
|
m.dbg("container selected: %d", m.containerCursor)
|
|
m = m.loadContainerView()
|
|
}
|
|
} else {
|
|
cmds = append(cmds, m.forwardToViewport(msg))
|
|
}
|
|
|
|
case msg.Code == tea.KeyEscape:
|
|
if !m.viewportAtBottom {
|
|
m.dbg("viewport: escape → goto bottom")
|
|
m.viewport.GotoBottom()
|
|
m.viewportAtBottom = true
|
|
}
|
|
|
|
case key.Matches(msg, m.keys.Start):
|
|
if p := m.activeProc(); p != nil {
|
|
if p.IsStandby() {
|
|
real, ok := m.promoteStandby()
|
|
if ok {
|
|
m.dbg("start: promoted standby proc=%s", real.Name)
|
|
send := m.mgr.Send()
|
|
go func() { _ = real.Start(send) }()
|
|
}
|
|
} else if !p.IsRunning() {
|
|
m.dbg("start: proc=%s", p.Name)
|
|
send := m.mgr.Send()
|
|
go func() { _ = p.Start(send) }()
|
|
}
|
|
}
|
|
|
|
case key.Matches(msg, m.keys.Restart):
|
|
if p := m.activeProc(); p != nil {
|
|
send := m.mgr.Send()
|
|
switch {
|
|
case p.IsStandby():
|
|
if real, ok := m.promoteStandby(); ok {
|
|
m.dbg("restart: promoted standby proc=%s", real.Name)
|
|
go func() { _ = real.Start(send) }()
|
|
}
|
|
case p.IsRunning():
|
|
m.dbg("restart: proc=%s", p.Name)
|
|
go p.Restart(send)
|
|
default:
|
|
// Crashed, finished, stopped, or never started — `r` means
|
|
// "run it (again)", so one-shot tasks rerun on a keypress.
|
|
m.dbg("restart (from %s): proc=%s", p.Status(), p.Name)
|
|
go func() { _ = p.Start(send) }()
|
|
}
|
|
}
|
|
|
|
case key.Matches(msg, m.keys.RestartAllFailed):
|
|
if started := m.restartAllFailed(); started > 0 {
|
|
m.dbg("restart failed: kicked off %d procs", started)
|
|
}
|
|
|
|
case key.Matches(msg, m.keys.Stop):
|
|
if p := m.activeProc(); p != nil && p.IsRunning() {
|
|
m.dbg("stop: proc=%s", p.Name)
|
|
p.Stop()
|
|
}
|
|
|
|
case key.Matches(msg, m.keys.ClearLogs):
|
|
if p := m.activeProc(); p != nil && p.IsRunning() {
|
|
m.dbg("clear logs: proc=%s", p.Name)
|
|
p.ClearLines()
|
|
var loadCmds []tea.Cmd
|
|
m, loadCmds = m.loadActiveProc()
|
|
cmds = append(cmds, loadCmds...)
|
|
}
|
|
|
|
case key.Matches(msg, m.keys.SearchMode):
|
|
m.searchMode = true
|
|
m.clearSearch()
|
|
m = m.applySize()
|
|
|
|
case key.Matches(msg, m.keys.CopyMode):
|
|
m.copyMode = true
|
|
m = m.applySize()
|
|
m.copyCursor = m.viewport.YOffset()
|
|
m.copyAnchor = -1
|
|
m.applyCopyStyle()
|
|
m.dbg("copy mode: enter at line %d", m.copyCursor)
|
|
|
|
case key.Matches(msg, m.keys.Sort):
|
|
m.sortMode = (m.sortMode + 1) % SortMode(sortModeCount)
|
|
m.sortServices()
|
|
m.dbg("sort: %s", m.sortMode)
|
|
|
|
case key.Matches(msg, m.keys.Group):
|
|
m.cycleGroup()
|
|
m.rebuildSidebarEntries()
|
|
m.ensureSidebarCursorVisible()
|
|
m.dbg("group: %s", m.activeGroupDim())
|
|
|
|
case key.Matches(msg, m.keys.ShowAll):
|
|
m.toggleShowAll()
|
|
m.dbg("show all: %v (%d standby)", m.showAllRegProcs, len(m.standbyRegProcs))
|
|
|
|
case key.Matches(msg, m.keys.InfoMode):
|
|
m.infoMode = true
|
|
m.toggleMetricsOnSelectedProc()
|
|
m.refreshInfoContent()
|
|
m.viewport.GotoTop()
|
|
m.dbg("info mode: enter")
|
|
|
|
case key.Matches(msg, m.keys.InputMode):
|
|
// Enter explicit input mode so keystrokes reach a proc that's waiting on
|
|
// stdin even when the HasPrompt heuristic didn't catch it. Skipped while
|
|
// a committed search (query survives a proc switch) is pending, so the
|
|
// footer's "enter: navigate" hint doesn't get hijacked into input mode.
|
|
if p != nil && m.focusedPane == focusOutput && p.IsRunning() && m.searchQuery == "" {
|
|
m.inputMode = true
|
|
m.inputBuffer = ""
|
|
m.dbg("input mode: enter")
|
|
}
|
|
|
|
case key.Matches(msg, m.keys.SetupMode):
|
|
m = m.enterSetupMode()
|
|
m.dbg("setup mode: enter")
|
|
|
|
case key.Matches(msg, m.keys.Mascot):
|
|
m.mascotMode = !m.mascotMode
|
|
m.dbg("mascot mode: %v", m.mascotMode)
|
|
if m.mascotMode {
|
|
m.mascotX = 0
|
|
m.mascotDir = 1
|
|
m.mascotFrame = 0
|
|
cmds = append(cmds, mascotTick())
|
|
}
|
|
|
|
case key.Matches(msg, m.keys.ProcViewer):
|
|
if p := m.activeProc(); p != nil {
|
|
if pid := p.PID(); pid > 0 {
|
|
cmd := procViewerCmd(pid)
|
|
if cmd != nil {
|
|
m.dbg("proc viewer: %s %v", cmd.Path, cmd.Args)
|
|
return m, tea.ExecProcess(cmd, nil)
|
|
}
|
|
}
|
|
}
|
|
|
|
case key.Matches(msg, m.keys.LazyDocker):
|
|
if path, err := exec.LookPath("lazydocker"); err == nil {
|
|
args := []string{}
|
|
for _, f := range m.composeArgs.Files {
|
|
args = append(args, "-f", f)
|
|
}
|
|
m.dbg("lazydocker: %v", args)
|
|
c := exec.Command(path, args...)
|
|
return m, tea.ExecProcess(c, nil)
|
|
}
|
|
|
|
default:
|
|
if m.handleViewportNavKey(msg, &cmds) {
|
|
break
|
|
}
|
|
if m.focusedPane == focusOutput {
|
|
cmds = append(cmds, m.forwardToViewport(msg))
|
|
}
|
|
}
|
|
|
|
return m, tea.Batch(cmds...)
|
|
}
|