36b3af2e3d
PR Check / Code Quality: Format (push) Failing after 1s
PR Check / Code Quality: Lint (darwin) (push) Failing after 0s
PR Check / Code Quality: Lint (freebsd) (push) Failing after 1s
PR Check / Code Quality: Lint (windows) (push) Failing after 1s
PR Check / Code Quality: Lint (linux) (push) Failing after 1s
PR Check / Security: Vulnerability Scan (push) Failing after 0s
Update Documentation / update-docs (push) Failing after 2s
PR Check / Code Quality: Vendor (push) Failing after 1s
PR Check / Code Quality: Coverage (push) Failing after 0s
PR Check / Tests: Unit (macos-latest) (push) Has been cancelled
PR Check / Tests: Unit (ubuntu-24.04) (push) Has been cancelled
PR Check / Tests: Unit (ubuntu-24.04-arm) (push) Has been cancelled
PR Check / Tests: Unit (windows-latest) (push) Has been cancelled
1670 lines
42 KiB
Go
1670 lines
42 KiB
Go
package tui
|
||
|
||
import (
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/charmbracelet/bubbles/textinput"
|
||
tea "github.com/charmbracelet/bubbletea"
|
||
"github.com/pranshuparmar/witr/internal/proc"
|
||
"github.com/pranshuparmar/witr/pkg/model"
|
||
)
|
||
|
||
type treeMsg model.Result
|
||
|
||
type debounceMsg struct {
|
||
id int
|
||
pid int
|
||
}
|
||
|
||
type tickMsg time.Time
|
||
|
||
// waitTick drives the periodic list refresh; 3s mirrors top's default cadence.
|
||
func waitTick() tea.Cmd {
|
||
return tea.Tick(refreshInterval, func(t time.Time) tea.Msg {
|
||
return tickMsg(t)
|
||
})
|
||
}
|
||
|
||
func (m MainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||
switch msg := msg.(type) {
|
||
case tickMsg:
|
||
return m.handleTick(msg)
|
||
case tea.MouseMsg:
|
||
return m.handleMouse(msg)
|
||
case tea.KeyMsg:
|
||
return m.handleKey(msg)
|
||
case tea.WindowSizeMsg:
|
||
return m.handleResize(msg)
|
||
case []model.Process:
|
||
return m.handleProcessList(msg)
|
||
case debounceMsg:
|
||
return m.handleDebounce(msg)
|
||
case []model.OpenPort:
|
||
return m.handlePortList(msg)
|
||
case []*model.ContainerMatch:
|
||
return m.handleContainerList(msg)
|
||
case []*model.LockedFile:
|
||
return m.handleLockList(msg)
|
||
case treeMsg:
|
||
return m.handleTree(msg)
|
||
case model.Result:
|
||
return m.handleResult(msg)
|
||
case *model.ContainerMatch:
|
||
return m.handleContainerDetail(msg)
|
||
case error:
|
||
return m.handleError(msg)
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
func (m MainModel) handleTick(msg tickMsg) (tea.Model, tea.Cmd) {
|
||
var cmd tea.Cmd
|
||
if m.state == stateList && !m.quitting && !m.input.Focused() && !m.portInput.Focused() && !m.containerInput.Focused() && !m.lockInput.Focused() && m.refreshDue() {
|
||
m.lastRefresh = time.Now()
|
||
m.refreshStartedAt = m.lastRefresh
|
||
cmd = m.refreshProcesses()
|
||
switch m.activeTab {
|
||
case tabPorts:
|
||
cmd = tea.Batch(cmd, m.refreshPorts())
|
||
case tabContainers:
|
||
cmd = tea.Batch(cmd, m.refreshContainers())
|
||
case tabLocks:
|
||
cmd = tea.Batch(cmd, m.refreshLocks())
|
||
}
|
||
}
|
||
return m, tea.Batch(cmd, waitTick())
|
||
}
|
||
|
||
// refreshDue reports whether enough time has elapsed since the last background
|
||
// refresh, given the adaptive cadence. The timer still ticks at the base rate;
|
||
// this just decides whether a given tick does the (expensive) re-enumeration.
|
||
// refreshDue reports whether the next background refresh should run: the
|
||
// adaptive interval has elapsed and no refresh is already in flight, so a slow
|
||
// refresh can't overlap the next tick. A pathologically old in-flight marker is
|
||
// ignored so a lost result can't wedge refreshing permanently.
|
||
func (m MainModel) refreshDue() bool {
|
||
if !m.refreshStartedAt.IsZero() && time.Since(m.refreshStartedAt) < maxRefreshInterval {
|
||
return false
|
||
}
|
||
every := m.refreshEvery
|
||
if every < refreshInterval {
|
||
every = refreshInterval
|
||
}
|
||
return time.Since(m.lastRefresh) >= every
|
||
}
|
||
|
||
// adjustRefreshInterval applies one refresh-duration sample to the adaptive
|
||
// cadence. After backoffStreak consecutive refreshes over slowFraction of the
|
||
// interval it grows by refreshStep (capped at maxRefreshInterval); after
|
||
// backoffStreak under fastFraction it shrinks by refreshStep (floored at
|
||
// refreshInterval); the band between is stable. Returns the new interval and
|
||
// the updated slow/fast streak counters.
|
||
func adjustRefreshInterval(interval, took time.Duration, slow, fast int) (time.Duration, int, int) {
|
||
switch {
|
||
case took > time.Duration(float64(interval)*slowFraction):
|
||
slow, fast = slow+1, 0
|
||
if slow >= backoffStreak {
|
||
interval += refreshStep
|
||
if interval > maxRefreshInterval {
|
||
interval = maxRefreshInterval
|
||
}
|
||
slow = 0
|
||
}
|
||
case took < time.Duration(float64(interval)*fastFraction):
|
||
fast, slow = fast+1, 0
|
||
if fast >= backoffStreak {
|
||
interval -= refreshStep
|
||
if interval < refreshInterval {
|
||
interval = refreshInterval
|
||
}
|
||
fast = 0
|
||
}
|
||
default:
|
||
slow, fast = 0, 0
|
||
}
|
||
return interval, slow, fast
|
||
}
|
||
|
||
func (m MainModel) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
||
m.statusMsg = "" // clear any transient error on interaction
|
||
if msg.Action != tea.MouseActionPress && msg.Action != tea.MouseActionMotion && msg.Action != tea.MouseActionRelease {
|
||
return m, nil
|
||
}
|
||
|
||
// Required for Windows: isClick is true only for real pointer presses (not scroll wheel).
|
||
isWheel := msg.Button == tea.MouseButtonWheelUp ||
|
||
msg.Button == tea.MouseButtonWheelDown ||
|
||
msg.Button == tea.MouseButtonWheelLeft ||
|
||
msg.Button == tea.MouseButtonWheelRight
|
||
isClick := msg.Action == tea.MouseActionPress && !isWheel
|
||
|
||
isDoubleClick := false
|
||
if isClick {
|
||
clickDuration := time.Since(m.lastClickTime)
|
||
if clickDuration < doubleClickThreshold {
|
||
distX := m.lastClickX - msg.X
|
||
distY := m.lastClickY - msg.Y
|
||
if distX < 0 {
|
||
distX = -distX
|
||
}
|
||
if distY < 0 {
|
||
distY = -distY
|
||
}
|
||
if distX <= 2 && distY <= 1 {
|
||
isDoubleClick = true
|
||
}
|
||
}
|
||
m.lastClickTime = time.Now()
|
||
m.lastClickX = msg.X
|
||
m.lastClickY = msg.Y
|
||
}
|
||
|
||
// Handle "witr" Title Click (Home)
|
||
if msg.Y == 1 && isClick && msg.X >= 1 && msg.X <= 6 {
|
||
m.state = stateList
|
||
m.activeTab = tabProcesses
|
||
return m, m.refreshProcesses()
|
||
}
|
||
|
||
// Handle Detail View Clicks
|
||
if m.state == stateDetail {
|
||
return m.handleDetailMouse(msg, isClick)
|
||
}
|
||
|
||
// Handle Search Clicks: blur search if clicking outside the input row
|
||
if isClick && msg.Y != 5 {
|
||
if m.input.Focused() {
|
||
m.input.Blur()
|
||
}
|
||
if m.portInput.Focused() {
|
||
m.portInput.Blur()
|
||
}
|
||
if m.containerInput.Focused() {
|
||
m.containerInput.Blur()
|
||
}
|
||
if m.lockInput.Focused() {
|
||
m.lockInput.Blur()
|
||
}
|
||
}
|
||
|
||
// Tabs. X ranges are inactiveTabStyle widths: "1. Processes"=14,
|
||
// "2. Ports"=10, "3. Containers"=15, "4. Locks"=10 (inc. 1ch padding).
|
||
if msg.Y == 1 && isClick {
|
||
if msg.X >= 8 && msg.X < 22 { // "1. Processes"
|
||
if m.activeTab != tabProcesses {
|
||
m.activeTab = tabProcesses
|
||
m.listFocus = focusMain
|
||
return m, nil
|
||
}
|
||
} else if msg.X >= 22 && msg.X < 32 { // "2. Ports"
|
||
if m.activeTab != tabPorts {
|
||
m.activeTab = tabPorts
|
||
m.listFocus = focusMain
|
||
return m, m.refreshPorts()
|
||
}
|
||
} else if msg.X >= 32 && msg.X < 47 { // "3. Containers"
|
||
if m.activeTab != tabContainers {
|
||
m.activeTab = tabContainers
|
||
m.listFocus = focusMain
|
||
return m, m.refreshContainers()
|
||
}
|
||
} else if locksTabEnabled && msg.X >= 47 && msg.X < 57 { // "4. Locks"
|
||
if m.activeTab != tabLocks {
|
||
m.activeTab = tabLocks
|
||
m.listFocus = focusMain
|
||
return m, m.refreshLocks()
|
||
}
|
||
}
|
||
}
|
||
|
||
// Handle Search Input Clicks
|
||
if msg.Y == 5 && isClick && m.state == stateList {
|
||
switch m.activeTab {
|
||
case tabProcesses:
|
||
m.input.Focus()
|
||
case tabPorts:
|
||
m.portInput.Focus()
|
||
case tabContainers:
|
||
m.containerInput.Focus()
|
||
case tabLocks:
|
||
m.lockInput.Focus()
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
// Handle Content Area Clicks
|
||
if msg.Y >= 7 {
|
||
contentX := msg.X - 2
|
||
if contentX < 0 {
|
||
return m, nil
|
||
}
|
||
|
||
switch m.activeTab {
|
||
case tabProcesses:
|
||
return m.handleProcessAreaMouse(msg, contentX, isClick, isWheel, isDoubleClick)
|
||
case tabPorts:
|
||
return m.handlePortAreaMouse(msg, contentX, isClick, isWheel, isDoubleClick)
|
||
case tabContainers:
|
||
return m.handleContainerAreaMouse(msg, contentX, isClick, isWheel, isDoubleClick)
|
||
case tabLocks:
|
||
return m.handleLockAreaMouse(msg, contentX, isClick, isWheel, isDoubleClick)
|
||
}
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
func (m MainModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||
m.statusMsg = "" // clear any transient error on interaction
|
||
switch msg.String() {
|
||
case "ctrl+c":
|
||
m.quitting = true
|
||
return m, tea.Quit
|
||
case "1":
|
||
if !m.input.Focused() && !m.portInput.Focused() && !m.containerInput.Focused() {
|
||
m.activeTab = tabProcesses
|
||
m.listFocus = focusMain
|
||
return m, nil
|
||
}
|
||
case "2":
|
||
if !m.input.Focused() && !m.portInput.Focused() && !m.containerInput.Focused() {
|
||
m.activeTab = tabPorts
|
||
m.listFocus = focusMain
|
||
m.portTable.Focus()
|
||
m.portDetailTable.Blur()
|
||
return m, m.refreshPorts()
|
||
}
|
||
case "3":
|
||
if !m.input.Focused() && !m.portInput.Focused() && !m.containerInput.Focused() && !m.lockInput.Focused() {
|
||
m.activeTab = tabContainers
|
||
m.listFocus = focusMain
|
||
return m, m.refreshContainers()
|
||
}
|
||
case "4":
|
||
if locksTabEnabled && !m.input.Focused() && !m.portInput.Focused() && !m.containerInput.Focused() && !m.lockInput.Focused() {
|
||
m.activeTab = tabLocks
|
||
m.listFocus = focusMain
|
||
return m, m.refreshLocks()
|
||
}
|
||
}
|
||
|
||
if m.state == stateList {
|
||
return m.handleListKey(msg)
|
||
} else if m.state == stateDetail {
|
||
return m.handleDetailKey(msg)
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
func (m MainModel) handleResize(msg tea.WindowSizeMsg) (tea.Model, tea.Cmd) {
|
||
m.width = msg.Width
|
||
m.height = msg.Height
|
||
|
||
availableWidth := msg.Width - 6
|
||
if availableWidth < 0 {
|
||
availableWidth = 0
|
||
}
|
||
|
||
processListHeight := msg.Height - 11
|
||
if processListHeight < 5 {
|
||
processListHeight = 5
|
||
}
|
||
|
||
processListPaneWidth := int(float64(availableWidth) * listPaneRatio)
|
||
if processListPaneWidth < 10 {
|
||
processListPaneWidth = 10
|
||
}
|
||
|
||
processTablePadding := 4
|
||
processListWidth := processListPaneWidth - processTablePadding
|
||
if processListWidth < 10 {
|
||
processListWidth = 10
|
||
}
|
||
|
||
fixedColumnsWidth := 81 // PID(8)+Name(20)+User(12)+CPU(6)+Mem(16)+Started(19)
|
||
cmdWidth := processListWidth - fixedColumnsWidth - 12
|
||
m.showCmdCol = cmdWidth >= 15
|
||
if cmdWidth < 10 {
|
||
cmdWidth = 10
|
||
}
|
||
|
||
columns := m.getColumns()
|
||
if m.showCmdCol {
|
||
columns[6].Width = cmdWidth
|
||
}
|
||
m.table.SetWidth(processListWidth)
|
||
m.table.SetHeight(processListHeight)
|
||
m.table.SetRows(nil)
|
||
m.table.SetColumns(columns)
|
||
m.filterProcesses()
|
||
|
||
treeWidth := availableWidth - processListPaneWidth - 4
|
||
if treeWidth < 10 {
|
||
treeWidth = 10
|
||
}
|
||
m.treeViewport.Width = treeWidth
|
||
m.treeViewport.Height = processListHeight - 2
|
||
if m.treeViewport.Height < 0 {
|
||
m.treeViewport.Height = 0
|
||
}
|
||
portListHeight := processListHeight
|
||
|
||
portPaneWidth := int(float64(availableWidth) * portPaneRatio)
|
||
if portPaneWidth < 0 {
|
||
portPaneWidth = 0
|
||
}
|
||
|
||
tablePadding := 4
|
||
portTableWidth := portPaneWidth - tablePadding
|
||
if portTableWidth < 0 {
|
||
portTableWidth = 0
|
||
}
|
||
|
||
portColumns := m.getPortColumns()
|
||
fixedPortWidth := 36
|
||
buffer := 6
|
||
addrWidth := portTableWidth - fixedPortWidth - buffer
|
||
if addrWidth < 10 {
|
||
addrWidth = 10
|
||
}
|
||
if len(portColumns) > 2 {
|
||
portColumns[2].Width = addrWidth
|
||
}
|
||
m.portTable.SetColumns(portColumns)
|
||
m.portTable.SetWidth(portTableWidth)
|
||
m.portTable.SetHeight(portListHeight)
|
||
|
||
portDetailWidth := availableWidth - portPaneWidth - 5
|
||
if portDetailWidth < 10 {
|
||
portDetailWidth = 10
|
||
}
|
||
|
||
pdCols := m.portDetailTable.Columns()
|
||
fixedPdWidth := 35
|
||
buffer = 6
|
||
cmdPdWidth := portDetailWidth - fixedPdWidth - buffer
|
||
if cmdPdWidth < 10 {
|
||
cmdPdWidth = 10
|
||
}
|
||
if len(pdCols) > 3 {
|
||
pdCols[3].Width = cmdPdWidth
|
||
}
|
||
// Center-align PID header in port detail table
|
||
if len(pdCols) > 0 {
|
||
pdCols[0].Title = centerHeader("PID", pdCols[0].Width)
|
||
}
|
||
m.portDetailTable.SetColumns(pdCols)
|
||
m.portDetailTable.SetWidth(portDetailWidth)
|
||
m.portDetailTable.SetHeight(portListHeight - 2)
|
||
|
||
m.containerTable.SetWidth(availableWidth)
|
||
m.containerTable.SetHeight(processListHeight)
|
||
// Size the trailing Command column so total column widths match the
|
||
// table width — bubbles table draws the header underline across
|
||
// SetWidth, so column overflow leaves a partial border.
|
||
containerCols := m.containerTable.Columns()
|
||
fixedCC := 0
|
||
const cmdColIdx = 6
|
||
for i, c := range containerCols {
|
||
if i == cmdColIdx {
|
||
continue
|
||
}
|
||
fixedCC += c.Width
|
||
}
|
||
const cellPadding = 2 // tableHeaderStyle Padding(0, 1) → 1 char per side
|
||
paddingBudget := cellPadding * len(containerCols)
|
||
if cmdColIdx < len(containerCols) {
|
||
cmdWidth := availableWidth - fixedCC - paddingBudget
|
||
if cmdWidth < 10 {
|
||
cmdWidth = 10
|
||
}
|
||
containerCols[cmdColIdx].Width = cmdWidth
|
||
m.containerTable.SetColumns(containerCols)
|
||
}
|
||
|
||
m.lockTable.SetWidth(availableWidth)
|
||
m.lockTable.SetHeight(processListHeight)
|
||
// Last column (Path) absorbs remaining width, same pattern as Command.
|
||
lockCols := m.lockTable.Columns()
|
||
fixedLC := 0
|
||
pathColIdx := len(lockCols) - 1
|
||
for i, c := range lockCols {
|
||
if i == pathColIdx {
|
||
continue
|
||
}
|
||
fixedLC += c.Width
|
||
}
|
||
if pathColIdx >= 0 {
|
||
pathWidth := availableWidth - fixedLC - cellPadding*len(lockCols)
|
||
if pathWidth < 10 {
|
||
pathWidth = 10
|
||
}
|
||
lockCols[pathColIdx].Width = pathWidth
|
||
m.lockTable.SetColumns(lockCols)
|
||
}
|
||
|
||
vpHeight := msg.Height - 9
|
||
if vpHeight < 0 {
|
||
vpHeight = 0
|
||
}
|
||
detailViewWidth := int(float64(availableWidth) * detailPaneRatio)
|
||
envViewWidth := availableWidth - detailViewWidth - 4
|
||
|
||
m.viewport.Width = detailViewWidth - 4
|
||
m.viewport.Height = vpHeight
|
||
|
||
m.envViewport.Width = envViewWidth
|
||
if m.envViewport.Width < 0 {
|
||
m.envViewport.Width = 0
|
||
}
|
||
m.envViewport.Height = vpHeight
|
||
|
||
m.updatePortDetails()
|
||
return m, nil
|
||
}
|
||
|
||
func (m MainModel) handleProcessList(msg []model.Process) (tea.Model, tea.Cmd) {
|
||
// Feed this background refresh's measured duration into the adaptive cadence.
|
||
if !m.refreshStartedAt.IsZero() {
|
||
took := time.Since(m.refreshStartedAt)
|
||
m.refreshStartedAt = time.Time{}
|
||
m.refreshEvery, m.slowStreak, m.fastStreak = adjustRefreshInterval(m.refreshEvery, took, m.slowStreak, m.fastStreak)
|
||
}
|
||
|
||
var currentPID int
|
||
selectedRow := m.table.SelectedRow()
|
||
if len(selectedRow) > 0 {
|
||
fmt.Sscanf(selectedRow[0], "%d", ¤tPID)
|
||
}
|
||
|
||
m.processes = msg
|
||
m.sortProcesses()
|
||
m.filterProcesses()
|
||
|
||
newIdx := 0
|
||
found := false
|
||
if currentPID > 0 {
|
||
for i, p := range m.filtered {
|
||
if p.PID == currentPID {
|
||
newIdx = i
|
||
found = true
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
if len(m.filtered) > 0 {
|
||
if !found {
|
||
newIdx = 0
|
||
}
|
||
m.table.SetCursor(newIdx)
|
||
|
||
m.selectionID++
|
||
p := m.filtered[newIdx]
|
||
return m, m.fetchTree(p)
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
func (m MainModel) handleDebounce(msg debounceMsg) (tea.Model, tea.Cmd) {
|
||
if msg.id == m.selectionID {
|
||
var targetProc model.Process
|
||
found := false
|
||
row := m.table.SelectedRow()
|
||
if len(row) > 0 {
|
||
var pID int
|
||
fmt.Sscanf(row[0], "%d", &pID)
|
||
if pID == msg.pid {
|
||
idx := m.table.Cursor()
|
||
if idx >= 0 && idx < len(m.filtered) {
|
||
targetProc = m.filtered[idx]
|
||
found = true
|
||
}
|
||
}
|
||
}
|
||
if !found {
|
||
for _, p := range m.processes {
|
||
if p.PID == msg.pid {
|
||
targetProc = p
|
||
found = true
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
if found {
|
||
return m, m.fetchTree(targetProc)
|
||
}
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
func (m MainModel) handlePortList(msg []model.OpenPort) (tea.Model, tea.Cmd) {
|
||
m.ports = msg
|
||
m.updatePortTable()
|
||
m.updatePortDetails()
|
||
return m, nil
|
||
}
|
||
|
||
func (m MainModel) handleContainerList(msg []*model.ContainerMatch) (tea.Model, tea.Cmd) {
|
||
m.containers = msg
|
||
m.updateContainerTable()
|
||
return m, nil
|
||
}
|
||
|
||
func (m MainModel) handleLockList(msg []*model.LockedFile) (tea.Model, tea.Cmd) {
|
||
m.locks = msg
|
||
m.updateLockTable()
|
||
return m, nil
|
||
}
|
||
|
||
func (m MainModel) handleTree(msg treeMsg) (tea.Model, tea.Cmd) {
|
||
selected := m.table.SelectedRow()
|
||
if len(selected) > 0 {
|
||
var currentPID int
|
||
fmt.Sscanf(selected[0], "%d", ¤tPID)
|
||
if model.Result(msg).Process.PID == currentPID {
|
||
m.updateTreeViewport(model.Result(msg))
|
||
}
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
func (m MainModel) handleResult(msg model.Result) (tea.Model, tea.Cmd) {
|
||
m.selectedDetail = &msg
|
||
m.selectedContainer = nil
|
||
m.updateDetailViewport()
|
||
m.updateEnvViewport()
|
||
return m, nil
|
||
}
|
||
|
||
func (m MainModel) handleContainerDetail(msg *model.ContainerMatch) (tea.Model, tea.Cmd) {
|
||
m.selectedContainer = msg
|
||
m.selectedDetail = nil
|
||
m.updateDetailViewport()
|
||
return m, nil
|
||
}
|
||
|
||
func (m MainModel) handleError(msg error) (tea.Model, tea.Cmd) {
|
||
// Revert to list view on any error
|
||
m.state = stateList
|
||
m.selectedDetail = nil
|
||
m.selectedContainer = nil
|
||
m.statusMsg = fmt.Sprintf("Error: %v", msg)
|
||
return m, m.refreshProcesses()
|
||
}
|
||
|
||
func (m MainModel) handleDetailMouse(msg tea.MouseMsg, isClick bool) (tea.Model, tea.Cmd) {
|
||
// Container detail has a single full-width viewport — no env pane,
|
||
// so everything (clicks + wheel) goes straight to m.viewport.
|
||
if m.selectedContainer != nil {
|
||
var cmd tea.Cmd
|
||
detailMsg := msg
|
||
detailMsg.Y -= 3
|
||
detailMsg.X -= 1
|
||
if detailMsg.X >= 0 {
|
||
m.viewport, cmd = m.viewport.Update(detailMsg)
|
||
}
|
||
return m, cmd
|
||
}
|
||
|
||
if isClick {
|
||
availableWidth := m.width - 6
|
||
if availableWidth < 0 {
|
||
availableWidth = 0
|
||
}
|
||
detailWidth := int(float64(availableWidth) * detailPaneRatio)
|
||
contentX := msg.X - 2
|
||
|
||
if contentX < detailWidth {
|
||
m.detailFocus = focusDetail
|
||
} else {
|
||
m.detailFocus = focusEnv
|
||
}
|
||
}
|
||
|
||
var cmd tea.Cmd
|
||
detailMsg := msg
|
||
detailMsg.Y -= 3
|
||
|
||
if m.detailFocus == focusDetail {
|
||
detailMsg.X -= 1
|
||
if detailMsg.X >= 0 {
|
||
m.viewport, cmd = m.viewport.Update(detailMsg)
|
||
}
|
||
} else {
|
||
availableWidth := m.width - 6
|
||
if availableWidth < 0 {
|
||
availableWidth = 0
|
||
}
|
||
detailWidth := int(float64(availableWidth) * detailPaneRatio)
|
||
detailMsg.X -= (detailWidth + 2)
|
||
if detailMsg.X >= 0 {
|
||
m.envViewport, cmd = m.envViewport.Update(detailMsg)
|
||
}
|
||
}
|
||
return m, cmd
|
||
}
|
||
|
||
func (m MainModel) handleProcessAreaMouse(msg tea.MouseMsg, contentX int, isClick, isWheel, isDoubleClick bool) (tea.Model, tea.Cmd) {
|
||
availableWidth := m.width - 6
|
||
processListPaneWidth := int(float64(availableWidth) * listPaneRatio)
|
||
if processListPaneWidth < 10 {
|
||
processListPaneWidth = 10
|
||
}
|
||
|
||
if contentX < processListPaneWidth {
|
||
if isClick {
|
||
m.listFocus = focusMain
|
||
|
||
if msg.Y == 7 {
|
||
m.handleProcessHeaderClick(contentX)
|
||
return m, nil
|
||
}
|
||
}
|
||
|
||
var cmd tea.Cmd
|
||
if isWheel {
|
||
// Convert wheel to key so the table scrolls by one row
|
||
// without jumping the cursor to the mouse Y position.
|
||
var keyMsg tea.KeyMsg
|
||
switch msg.Button {
|
||
case tea.MouseButtonWheelUp:
|
||
keyMsg = tea.KeyMsg{Type: tea.KeyUp}
|
||
case tea.MouseButtonWheelDown:
|
||
keyMsg = tea.KeyMsg{Type: tea.KeyDown}
|
||
}
|
||
prevCursor := m.table.Cursor()
|
||
m.table, cmd = m.table.Update(keyMsg)
|
||
if m.table.Cursor() != prevCursor {
|
||
selected := m.table.SelectedRow()
|
||
if len(selected) > 0 {
|
||
pid := 0
|
||
fmt.Sscanf(selected[0], "%d", &pid)
|
||
m.selectionID++
|
||
id := m.selectionID
|
||
return m, tea.Batch(cmd, tea.Tick(selectionDebounce, func(_ time.Time) tea.Msg {
|
||
return debounceMsg{id: id, pid: pid}
|
||
}))
|
||
}
|
||
}
|
||
return m, cmd
|
||
}
|
||
|
||
tableMsg := msg
|
||
tableMsg.X -= 2
|
||
tableMsg.Y -= 7
|
||
|
||
// Manual Row Selection
|
||
if isClick && tableMsg.Y >= 0 {
|
||
view := m.table.View()
|
||
lines := strings.Split(view, "\n")
|
||
if tableMsg.Y < len(lines) {
|
||
line := stripAnsi(lines[tableMsg.Y])
|
||
fields := strings.Fields(line)
|
||
var pid int
|
||
found := false
|
||
for _, f := range fields {
|
||
if p, err := fmt.Sscanf(f, "%d", &pid); err == nil && p > 0 && pid > 0 {
|
||
found = true
|
||
break
|
||
}
|
||
}
|
||
|
||
if found {
|
||
targetIdx := -1
|
||
rows := m.table.Rows()
|
||
for i, row := range rows {
|
||
if len(row) > 0 {
|
||
var rowPID int
|
||
if _, err := fmt.Sscanf(row[0], "%d", &rowPID); err == nil && rowPID == pid {
|
||
targetIdx = i
|
||
break
|
||
}
|
||
}
|
||
}
|
||
if targetIdx >= 0 {
|
||
currentIdx := m.table.Cursor()
|
||
diff := targetIdx - currentIdx
|
||
stepKey := tea.KeyMsg{Type: tea.KeyDown}
|
||
if diff < 0 {
|
||
stepKey = tea.KeyMsg{Type: tea.KeyUp}
|
||
diff = -diff
|
||
}
|
||
for j := 0; j < diff; j++ {
|
||
m.table, _ = m.table.Update(stepKey)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Row selection check using translated Y
|
||
if isClick && tableMsg.Y > 0 && tableMsg.Y <= m.table.Height() {
|
||
selected := m.table.SelectedRow()
|
||
if len(selected) > 0 {
|
||
pid := 0
|
||
fmt.Sscanf(selected[0], "%d", &pid)
|
||
|
||
// Double Click Action: Open Detail
|
||
if isDoubleClick {
|
||
m.state = stateDetail
|
||
m.viewport.GotoTop()
|
||
m.envViewport.GotoTop()
|
||
return m, m.fetchProcessDetail(pid)
|
||
}
|
||
|
||
m.selectionID++
|
||
id := m.selectionID
|
||
debounceCmd := tea.Tick(selectionDebounce, func(_ time.Time) tea.Msg {
|
||
return debounceMsg{id: id, pid: pid}
|
||
})
|
||
return m, tea.Batch(cmd, debounceCmd)
|
||
}
|
||
}
|
||
return m, cmd
|
||
|
||
} else {
|
||
if isClick {
|
||
m.listFocus = focusSide
|
||
// Translate click to tree cursor position
|
||
// Offset: border(1) + header(1) + spacer(1) + status(1) + input(1) + table-header-border(1) + table-header(1) + tree-header(1) + tree-header-border(1) + "Ancestry Tree:" label(1) = 10
|
||
treeY := msg.Y - 10
|
||
treeY += m.treeViewport.YOffset
|
||
if treeY >= 0 && treeY < len(m.treePIDs) {
|
||
m.treeCursor = treeY
|
||
m.rerenderTree()
|
||
|
||
if isDoubleClick {
|
||
pid := m.treePIDs[m.treeCursor]
|
||
if pid > 0 {
|
||
m.state = stateDetail
|
||
m.viewport.GotoTop()
|
||
m.envViewport.GotoTop()
|
||
return m, m.fetchProcessDetail(pid)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
var cmd tea.Cmd
|
||
// Forward wheel events to viewport for scrolling
|
||
if isWheel {
|
||
treeMsg := msg
|
||
treeMsg.X -= (6 + processListPaneWidth)
|
||
treeMsg.Y -= 8
|
||
if treeMsg.X >= 0 && treeMsg.Y >= 0 {
|
||
m.treeViewport, cmd = m.treeViewport.Update(treeMsg)
|
||
}
|
||
}
|
||
return m, cmd
|
||
}
|
||
}
|
||
|
||
func (m MainModel) handlePortAreaMouse(msg tea.MouseMsg, contentX int, isClick, isWheel, isDoubleClick bool) (tea.Model, tea.Cmd) {
|
||
availableWidth := m.width - 6
|
||
portPaneWidth := int(float64(availableWidth) * portPaneRatio)
|
||
|
||
if contentX < portPaneWidth {
|
||
if isClick {
|
||
m.listFocus = focusMain
|
||
m.portDetailTable.Blur()
|
||
m.portTable.Focus()
|
||
|
||
if msg.Y == 7 {
|
||
m.handlePortHeaderClick(contentX)
|
||
return m, nil
|
||
}
|
||
}
|
||
|
||
var cmd tea.Cmd
|
||
prevSelected := m.portTable.Cursor()
|
||
|
||
if isWheel {
|
||
// Convert wheel to key so the port table scrolls by one row.
|
||
var keyMsg tea.KeyMsg
|
||
switch msg.Button {
|
||
case tea.MouseButtonWheelUp:
|
||
keyMsg = tea.KeyMsg{Type: tea.KeyUp}
|
||
case tea.MouseButtonWheelDown:
|
||
keyMsg = tea.KeyMsg{Type: tea.KeyDown}
|
||
}
|
||
m.portTable, cmd = m.portTable.Update(keyMsg)
|
||
if m.portTable.Cursor() != prevSelected {
|
||
m.updatePortDetails()
|
||
}
|
||
return m, cmd
|
||
}
|
||
|
||
// Translate for Port Table
|
||
portMsg := msg
|
||
portMsg.X -= 2
|
||
portMsg.Y -= 7
|
||
if portMsg.X >= 0 && portMsg.Y >= 0 {
|
||
m.portTable, cmd = m.portTable.Update(portMsg)
|
||
}
|
||
|
||
// Manual Row Selection for Port Table
|
||
if isClick && portMsg.Y >= 0 {
|
||
view := m.portTable.View()
|
||
lines := strings.Split(view, "\n")
|
||
if portMsg.Y < len(lines) {
|
||
line := stripAnsi(lines[portMsg.Y])
|
||
fields := strings.Fields(line)
|
||
var port int
|
||
var protocol string
|
||
|
||
if len(fields) >= 2 {
|
||
if p, err := fmt.Sscanf(fields[0], "%d", &port); err == nil && p > 0 && port > 0 {
|
||
protocol = fields[1]
|
||
}
|
||
}
|
||
|
||
if port > 0 {
|
||
rows := m.portTable.Rows()
|
||
for i, row := range rows {
|
||
if len(row) >= 2 {
|
||
if p, err := fmt.Sscanf(row[0], "%d", new(int)); err == nil && p > 0 {
|
||
var rowPort int
|
||
fmt.Sscanf(row[0], "%d", &rowPort)
|
||
if rowPort == port && strings.EqualFold(row[1], protocol) {
|
||
m.portTable.SetCursor(i)
|
||
break
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if m.portTable.Cursor() != prevSelected {
|
||
m.updatePortDetails()
|
||
}
|
||
|
||
// Double Click (Ports): Focus Attached Processes
|
||
if isDoubleClick && isClick && portMsg.Y > 0 {
|
||
if portMsg.Y <= m.portTable.Height() {
|
||
m.listFocus = focusSide
|
||
m.portTable.Blur()
|
||
m.portDetailTable.Focus()
|
||
return m, cmd
|
||
}
|
||
}
|
||
|
||
return m, cmd
|
||
|
||
} else {
|
||
if isClick {
|
||
m.listFocus = focusSide
|
||
m.portTable.Blur()
|
||
m.portDetailTable.Focus()
|
||
}
|
||
|
||
var cmd tea.Cmd
|
||
detailMsg := msg
|
||
detailMsg.X -= (4 + portPaneWidth)
|
||
detailMsg.Y -= 9
|
||
if detailMsg.X >= 0 && detailMsg.Y >= 0 {
|
||
m.portDetailTable, cmd = m.portDetailTable.Update(detailMsg)
|
||
}
|
||
|
||
if msg.Action == tea.MouseActionPress && detailMsg.Y >= 0 {
|
||
view := m.portDetailTable.View()
|
||
lines := strings.Split(view, "\n")
|
||
if detailMsg.Y < len(lines) {
|
||
line := stripAnsi(lines[detailMsg.Y])
|
||
fields := strings.Fields(line)
|
||
var pid int
|
||
found := false
|
||
for _, f := range fields {
|
||
if p, err := fmt.Sscanf(f, "%d", &pid); err == nil && p > 0 && pid > 0 {
|
||
found = true
|
||
break
|
||
}
|
||
}
|
||
if found {
|
||
rows := m.portDetailTable.Rows()
|
||
for i, row := range rows {
|
||
if len(row) > 0 {
|
||
var rowPID int
|
||
n, _ := fmt.Sscanf(row[0], "%d", &rowPID)
|
||
if n == 1 && rowPID == pid {
|
||
m.portDetailTable.SetCursor(i)
|
||
break
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Double Click (Attached Processes): Open Detail
|
||
if isDoubleClick && isClick && detailMsg.Y > 0 {
|
||
selected := m.portDetailTable.SelectedRow()
|
||
if len(selected) > 0 {
|
||
pid := 0
|
||
fmt.Sscanf(selected[0], "%d", &pid)
|
||
if pid > 0 {
|
||
m.state = stateDetail
|
||
m.viewport.GotoTop()
|
||
m.envViewport.GotoTop()
|
||
return m, m.fetchProcessDetail(pid)
|
||
}
|
||
}
|
||
}
|
||
return m, cmd
|
||
}
|
||
}
|
||
|
||
func (m MainModel) handleContainerAreaMouse(msg tea.MouseMsg, contentX int, isClick, isWheel, isDoubleClick bool) (tea.Model, tea.Cmd) {
|
||
if isClick {
|
||
m.listFocus = focusMain
|
||
if msg.Y == 7 {
|
||
m.handleContainerHeaderClick(contentX)
|
||
return m, nil
|
||
}
|
||
}
|
||
|
||
if isWheel {
|
||
switch msg.Button {
|
||
case tea.MouseButtonWheelUp:
|
||
m.containerTable.MoveUp(1)
|
||
case tea.MouseButtonWheelDown:
|
||
m.containerTable.MoveDown(1)
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
if isClick && msg.Y > 7 {
|
||
// Step 1: move cursor to the clicked row (best-effort row match).
|
||
tableY := msg.Y - 7
|
||
lines := strings.Split(m.containerTable.View(), "\n")
|
||
if tableY < len(lines) {
|
||
clickedSig := normalizeRow(stripAnsi(lines[tableY]))
|
||
for i, row := range m.containerTable.Rows() {
|
||
if normalizeRow(strings.Join(row, " ")) == clickedSig {
|
||
diff := i - m.containerTable.Cursor()
|
||
if diff > 0 {
|
||
m.containerTable.MoveDown(diff)
|
||
} else if diff < 0 {
|
||
m.containerTable.MoveUp(-diff)
|
||
}
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
// Step 2 (independent): double-click opens detail for whatever
|
||
// row the cursor is currently on. Even if row matching above
|
||
// failed, this still works as long as the cursor is valid.
|
||
if isDoubleClick {
|
||
idx := m.containerTable.Cursor()
|
||
if idx >= 0 && idx < len(m.filteredContainers) {
|
||
match := m.filteredContainers[idx]
|
||
m.state = stateDetail
|
||
m.selectedDetail = nil
|
||
m.selectedContainer = nil
|
||
m.viewport.GotoTop()
|
||
return m, m.fetchContainerDetail(match)
|
||
}
|
||
}
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
func (m MainModel) handleLockAreaMouse(msg tea.MouseMsg, contentX int, isClick, isWheel, isDoubleClick bool) (tea.Model, tea.Cmd) {
|
||
if isClick {
|
||
m.listFocus = focusMain
|
||
if msg.Y == 7 {
|
||
m.handleLockHeaderClick(contentX)
|
||
return m, nil
|
||
}
|
||
}
|
||
|
||
if isWheel {
|
||
switch msg.Button {
|
||
case tea.MouseButtonWheelUp:
|
||
m.lockTable.MoveUp(1)
|
||
case tea.MouseButtonWheelDown:
|
||
m.lockTable.MoveDown(1)
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
if isClick && msg.Y > 7 {
|
||
// Step 1: move cursor to the clicked row.
|
||
tableY := msg.Y - 7
|
||
lines := strings.Split(m.lockTable.View(), "\n")
|
||
if tableY < len(lines) {
|
||
clickedSig := normalizeRow(stripAnsi(lines[tableY]))
|
||
for i, row := range m.lockTable.Rows() {
|
||
if normalizeRow(strings.Join(row, " ")) == clickedSig {
|
||
diff := i - m.lockTable.Cursor()
|
||
if diff > 0 {
|
||
m.lockTable.MoveDown(diff)
|
||
} else if diff < 0 {
|
||
m.lockTable.MoveUp(-diff)
|
||
}
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
// Step 2 (independent): double-click opens process detail.
|
||
if isDoubleClick {
|
||
idx := m.lockTable.Cursor()
|
||
if idx >= 0 && idx < len(m.filteredLocks) {
|
||
dblPID := m.filteredLocks[idx].PID
|
||
if dblPID > 0 {
|
||
m.state = stateDetail
|
||
m.selectedDetail = nil
|
||
m.selectedContainer = nil
|
||
m.viewport.GotoTop()
|
||
m.envViewport.GotoTop()
|
||
return m, m.fetchProcessDetail(dblPID)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
func (m MainModel) handleListKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||
if nm, cmd, handled := m.handleListFilterInput(msg); handled {
|
||
return nm, cmd
|
||
} else {
|
||
m = nm
|
||
}
|
||
|
||
switch msg.String() {
|
||
case "q", "Q", "esc":
|
||
m.quitting = true
|
||
return m, tea.Quit
|
||
case "enter":
|
||
if m.activeTab == tabLocks {
|
||
cursor := m.lockTable.Cursor()
|
||
if cursor >= 0 && cursor < len(m.filteredLocks) {
|
||
pid := m.filteredLocks[cursor].PID
|
||
if pid > 0 {
|
||
m.state = stateDetail
|
||
m.selectedDetail = nil
|
||
m.selectedContainer = nil
|
||
m.viewport.GotoTop()
|
||
m.envViewport.GotoTop()
|
||
return m, m.fetchProcessDetail(pid)
|
||
}
|
||
}
|
||
}
|
||
if m.activeTab == tabContainers {
|
||
cursor := m.containerTable.Cursor()
|
||
if cursor >= 0 && cursor < len(m.filteredContainers) {
|
||
match := m.filteredContainers[cursor]
|
||
m.state = stateDetail
|
||
m.selectedDetail = nil
|
||
m.selectedContainer = nil
|
||
m.viewport.GotoTop()
|
||
return m, m.fetchContainerDetail(match)
|
||
}
|
||
}
|
||
if m.activeTab == tabProcesses && m.listFocus == focusMain {
|
||
selected := m.table.SelectedRow()
|
||
if len(selected) > 0 {
|
||
pid := 0
|
||
fmt.Sscanf(selected[0], "%d", &pid)
|
||
if pid > 0 {
|
||
m.state = stateDetail
|
||
m.viewport.GotoTop()
|
||
m.envViewport.GotoTop()
|
||
return m, m.fetchProcessDetail(pid)
|
||
}
|
||
}
|
||
} else if m.activeTab == tabProcesses && m.listFocus == focusSide {
|
||
if m.treeCursor >= 0 && m.treeCursor < len(m.treePIDs) {
|
||
pid := m.treePIDs[m.treeCursor]
|
||
if pid > 0 {
|
||
m.state = stateDetail
|
||
m.viewport.GotoTop()
|
||
m.envViewport.GotoTop()
|
||
return m, m.fetchProcessDetail(pid)
|
||
}
|
||
}
|
||
} else if m.activeTab == tabPorts {
|
||
switch m.listFocus {
|
||
case focusMain:
|
||
m.listFocus = focusSide
|
||
m.portTable.Blur()
|
||
m.portDetailTable.Focus()
|
||
case focusSide:
|
||
selected := m.portDetailTable.SelectedRow()
|
||
if len(selected) > 0 {
|
||
pid := 0
|
||
fmt.Sscanf(selected[0], "%d", &pid)
|
||
if pid > 0 {
|
||
m.state = stateDetail
|
||
m.viewport.GotoTop()
|
||
m.envViewport.GotoTop()
|
||
return m, m.fetchProcessDetail(pid)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Focus Switching
|
||
case "tab", "right", "left", "l", "L", "h", "H":
|
||
if m.input.Focused() || m.portInput.Focused() || m.containerInput.Focused() || m.lockInput.Focused() {
|
||
break
|
||
}
|
||
// Tabs without a side panel — these keys are no-ops there.
|
||
if m.activeTab == tabContainers || m.activeTab == tabLocks {
|
||
return m, nil
|
||
}
|
||
if msg.String() == "tab" || msg.String() == "right" || msg.String() == "l" || msg.String() == "L" {
|
||
if m.listFocus == focusMain {
|
||
m.listFocus = focusSide
|
||
if m.activeTab == tabPorts {
|
||
m.portTable.Blur()
|
||
m.portDetailTable.Focus()
|
||
}
|
||
} else {
|
||
m.listFocus = focusMain
|
||
if m.activeTab == tabPorts {
|
||
m.portDetailTable.Blur()
|
||
m.portTable.Focus()
|
||
}
|
||
}
|
||
} else if msg.String() == "shift+tab" || msg.String() == "left" || msg.String() == "h" || msg.String() == "H" {
|
||
if m.listFocus == focusSide {
|
||
m.listFocus = focusMain
|
||
if m.activeTab == tabPorts {
|
||
m.portDetailTable.Blur()
|
||
m.portTable.Focus()
|
||
}
|
||
} else {
|
||
m.listFocus = focusSide
|
||
if m.activeTab == tabPorts {
|
||
m.portTable.Blur()
|
||
m.portDetailTable.Focus()
|
||
}
|
||
}
|
||
}
|
||
return m, nil
|
||
|
||
// Toggle All Ports
|
||
case "a", "A":
|
||
if m.activeTab == tabPorts {
|
||
m.showAllPorts = !m.showAllPorts
|
||
m.updatePortTable()
|
||
return m, nil
|
||
}
|
||
if m.activeTab == tabLocks {
|
||
m.showAllFiles = !m.showAllFiles
|
||
m.locks = nil
|
||
m.filteredLocks = nil
|
||
m.lockTable.SetRows(nil)
|
||
m.lockTable.SetCursor(0)
|
||
return m, m.refreshLocks()
|
||
}
|
||
|
||
// Sorting Keys (union across all tabs; per-tab dispatch below picks the relevant ones)
|
||
case "c", "C", "p", "P", "n", "N", "m", "M", "t", "T", "u", "U", "s", "S",
|
||
"i", "I", "r", "R", "g", "G", "f", "F":
|
||
if nm, cmd, handled := m.handleSortKey(msg); handled {
|
||
return nm, cmd
|
||
} else {
|
||
m = nm
|
||
}
|
||
}
|
||
|
||
return m.handleListNavKey(msg)
|
||
}
|
||
|
||
// pidIdentityChanged reports whether the live process for pid no longer matches
|
||
// the one captured in selectedDetail — i.e. it exited and the PID was recycled.
|
||
// It is the shared guard the destructive actions use so a signal or renice can't
|
||
// land on an unrelated process. Returns false when there is no snapshot to
|
||
// compare against.
|
||
func (m MainModel) pidIdentityChanged(pid int) bool {
|
||
if m.selectedDetail == nil {
|
||
return false
|
||
}
|
||
cur, err := proc.ReadProcess(pid)
|
||
return err != nil || !cur.StartedAt.Equal(m.selectedDetail.Process.StartedAt)
|
||
}
|
||
|
||
func (m MainModel) handleDetailKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||
pid := 0
|
||
if m.selectedDetail != nil {
|
||
pid = m.selectedDetail.Process.PID
|
||
}
|
||
|
||
// renice text input
|
||
if m.pendingAction == actionRenice {
|
||
switch msg.String() {
|
||
case "esc":
|
||
m.pendingAction = actionNone
|
||
m.reniceInput.SetValue("")
|
||
m.reniceInput.Blur()
|
||
case "enter":
|
||
val, verr := validateNiceValue(m.reniceInput.Value())
|
||
switch {
|
||
case verr != nil:
|
||
m.statusMsg = "Invalid nice value — enter a number between −20 and 19"
|
||
case m.pidIdentityChanged(pid):
|
||
m.statusMsg = fmt.Sprintf("PID %d changed since opened — refresh and retry", pid)
|
||
default:
|
||
if err := setNice(pid, val); err != nil {
|
||
m.statusMsg = fmt.Sprintf("Renice failed: %v", err)
|
||
} else {
|
||
m.statusMsg = fmt.Sprintf("PID %d reniced to %d", pid, val)
|
||
}
|
||
}
|
||
m.pendingAction = actionNone
|
||
m.reniceInput.SetValue("")
|
||
m.reniceInput.Blur()
|
||
default:
|
||
var inputCmd tea.Cmd
|
||
m.reniceInput, inputCmd = m.reniceInput.Update(msg)
|
||
return m, inputCmd
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
// confirmation prompt
|
||
if m.pendingAction != actionNone {
|
||
switch confirmKey(msg.String()) {
|
||
case confirmExecute:
|
||
// Re-validate the target: if the process exited and its PID
|
||
// was reused since the detail view opened, refuse to signal a
|
||
// different process. Identity is the PID plus its start time.
|
||
if m.pidIdentityChanged(pid) {
|
||
m.pendingAction = actionNone
|
||
m.statusMsg = fmt.Sprintf("PID %d changed since opened — refresh and retry", pid)
|
||
return m, nil
|
||
}
|
||
originalAction := m.pendingAction
|
||
var execErr error
|
||
switch originalAction {
|
||
case actionKill:
|
||
execErr = killProcess(pid)
|
||
case actionTerm:
|
||
execErr = termProcess(pid)
|
||
case actionPause:
|
||
execErr = pauseProcess(pid)
|
||
case actionResume:
|
||
execErr = resumeProcess(pid)
|
||
}
|
||
m.pendingAction = actionNone
|
||
if execErr != nil {
|
||
m.statusMsg = fmt.Sprintf("Error: %v", execErr)
|
||
return m, nil
|
||
}
|
||
switch originalAction {
|
||
case actionKill, actionTerm:
|
||
// Process is gone — go back to list
|
||
m.state = stateList
|
||
m.selectedDetail = nil
|
||
m.statusMsg = fmt.Sprintf("Signal sent to PID %d", pid)
|
||
return m, m.refreshProcesses()
|
||
default:
|
||
// Pause/Resume succeeded — stay in detail view
|
||
m.statusMsg = "Done"
|
||
return m, nil
|
||
}
|
||
case confirmCancel:
|
||
m.pendingAction = actionNone
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
// action menu
|
||
if m.actionMenuOpen {
|
||
if pending, closeMenu := actionMenuSelect(msg.String()); closeMenu {
|
||
m.actionMenuOpen = false
|
||
m.pendingAction = pending
|
||
if pending == actionRenice {
|
||
m.reniceInput.Focus()
|
||
return m, textinput.Blink
|
||
}
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
// detail view navigation
|
||
switch msg.String() {
|
||
case "esc", "q", "Q", "backspace":
|
||
m.state = stateList
|
||
m.selectedDetail = nil
|
||
m.selectedContainer = nil
|
||
m.detailFocus = focusDetail
|
||
m.actionMenuOpen = false
|
||
m.pendingAction = actionNone
|
||
m.reniceInput.SetValue("")
|
||
m.reniceInput.Blur()
|
||
if m.activeTab == tabContainers {
|
||
return m, m.refreshContainers()
|
||
}
|
||
return m, m.refreshProcesses()
|
||
case "a", "A":
|
||
if actionsSupported && m.selectedDetail != nil {
|
||
m.actionMenuOpen = true
|
||
}
|
||
return m, nil
|
||
case "left", "h", "H":
|
||
m.detailFocus = focusDetail
|
||
return m, nil
|
||
case "right", "l", "L":
|
||
m.detailFocus = focusEnv
|
||
return m, nil
|
||
case "tab":
|
||
if m.detailFocus == focusDetail {
|
||
m.detailFocus = focusEnv
|
||
} else {
|
||
m.detailFocus = focusDetail
|
||
}
|
||
return m, nil
|
||
default:
|
||
var cmd tea.Cmd
|
||
if m.detailFocus == focusDetail {
|
||
m.viewport, cmd = m.viewport.Update(msg)
|
||
} else {
|
||
m.envViewport, cmd = m.envViewport.Update(msg)
|
||
}
|
||
return m, cmd
|
||
}
|
||
}
|
||
|
||
func (m MainModel) handleListFilterInput(msg tea.KeyMsg) (MainModel, tea.Cmd, bool) {
|
||
if m.activeTab == tabLocks {
|
||
if m.lockInput.Focused() {
|
||
if msg.String() == "enter" || msg.String() == "esc" {
|
||
m.lockInput.Blur()
|
||
return m, nil, true
|
||
}
|
||
if msg.Type == tea.KeyUp || msg.Type == tea.KeyDown {
|
||
m.lockInput.Blur()
|
||
} else {
|
||
var inputCmd tea.Cmd
|
||
m.lockInput, inputCmd = m.lockInput.Update(msg)
|
||
m.updateLockTable()
|
||
m.lockTable.SetCursor(0)
|
||
return m, inputCmd, true
|
||
}
|
||
}
|
||
|
||
if msg.String() == "/" {
|
||
m.lockInput.Focus()
|
||
return m, textinput.Blink, true
|
||
}
|
||
} else if m.activeTab == tabContainers {
|
||
if m.containerInput.Focused() {
|
||
if msg.String() == "enter" || msg.String() == "esc" {
|
||
m.containerInput.Blur()
|
||
return m, nil, true
|
||
}
|
||
if msg.Type == tea.KeyUp || msg.Type == tea.KeyDown {
|
||
m.containerInput.Blur()
|
||
} else {
|
||
var inputCmd tea.Cmd
|
||
m.containerInput, inputCmd = m.containerInput.Update(msg)
|
||
m.updateContainerTable()
|
||
m.containerTable.SetCursor(0)
|
||
return m, inputCmd, true
|
||
}
|
||
}
|
||
|
||
if msg.String() == "/" {
|
||
m.containerInput.Focus()
|
||
return m, textinput.Blink, true
|
||
}
|
||
} else if m.activeTab == tabPorts {
|
||
if m.portInput.Focused() {
|
||
if msg.String() == "enter" || msg.String() == "esc" {
|
||
m.portInput.Blur()
|
||
return m, nil, true
|
||
}
|
||
if msg.Type == tea.KeyUp || msg.Type == tea.KeyDown {
|
||
m.portInput.Blur()
|
||
} else {
|
||
var inputCmd tea.Cmd
|
||
m.portInput, inputCmd = m.portInput.Update(msg)
|
||
m.updatePortTable()
|
||
m.portTable.SetCursor(0)
|
||
return m, inputCmd, true
|
||
}
|
||
}
|
||
|
||
if msg.String() == "/" {
|
||
m.portInput.Focus()
|
||
return m, textinput.Blink, true
|
||
}
|
||
} else {
|
||
if m.input.Focused() {
|
||
if msg.String() == "enter" || msg.String() == "esc" {
|
||
m.input.Blur()
|
||
return m, nil, true
|
||
}
|
||
if msg.Type == tea.KeyUp || msg.Type == tea.KeyDown {
|
||
m.input.Blur()
|
||
} else {
|
||
var inputCmd tea.Cmd
|
||
m.input, inputCmd = m.input.Update(msg)
|
||
m.filterProcesses()
|
||
|
||
m.table.SetCursor(0)
|
||
var treeCmd tea.Cmd
|
||
if len(m.filtered) > 0 {
|
||
selected := m.table.SelectedRow()
|
||
if len(selected) > 0 {
|
||
pid := 0
|
||
fmt.Sscanf(selected[0], "%d", &pid)
|
||
m.selectionID++
|
||
id := m.selectionID
|
||
treeCmd = tea.Tick(selectionDebounce, func(_ time.Time) tea.Msg {
|
||
return debounceMsg{id: id, pid: pid}
|
||
})
|
||
}
|
||
} else {
|
||
m.treeViewport.SetContent("")
|
||
}
|
||
return m, tea.Batch(inputCmd, treeCmd), true
|
||
}
|
||
}
|
||
|
||
if msg.String() == "/" {
|
||
m.input.Focus()
|
||
return m, textinput.Blink, true
|
||
}
|
||
}
|
||
return m, nil, false
|
||
}
|
||
|
||
func (m MainModel) handleSortKey(msg tea.KeyMsg) (MainModel, tea.Cmd, bool) {
|
||
switch m.activeTab {
|
||
case tabProcesses:
|
||
newCol := ""
|
||
switch msg.String() {
|
||
case "c", "C":
|
||
newCol = "cpu"
|
||
case "p", "P":
|
||
newCol = "pid"
|
||
case "n", "N":
|
||
newCol = "name"
|
||
case "m", "M":
|
||
newCol = "mem"
|
||
case "t", "T":
|
||
newCol = "time"
|
||
case "u", "U":
|
||
newCol = "user"
|
||
}
|
||
|
||
if newCol != "" {
|
||
if m.sortCol == newCol {
|
||
m.sortDesc = !m.sortDesc
|
||
} else {
|
||
m.sortCol = newCol
|
||
m.sortDesc = true
|
||
}
|
||
m.sortProcesses()
|
||
m.filterProcesses()
|
||
cols := m.table.Columns()
|
||
newCols := m.getColumns()
|
||
for i := range cols {
|
||
if i < len(newCols) {
|
||
newCols[i].Width = cols[i].Width
|
||
}
|
||
}
|
||
m.table.SetColumns(newCols)
|
||
return m, nil, true
|
||
}
|
||
case tabPorts:
|
||
newCol := ""
|
||
switch msg.String() {
|
||
case "p", "P":
|
||
newCol = "port"
|
||
case "t", "T":
|
||
newCol = "proto"
|
||
case "n", "N":
|
||
newCol = "addr"
|
||
case "s", "S":
|
||
newCol = "state"
|
||
}
|
||
|
||
if newCol != "" {
|
||
if m.sortPortCol == newCol {
|
||
m.sortPortDesc = !m.sortPortDesc
|
||
} else {
|
||
m.sortPortCol = newCol
|
||
m.sortPortDesc = false
|
||
}
|
||
m.updatePortTable()
|
||
return m, nil, true
|
||
}
|
||
|
||
case tabContainers:
|
||
newCol := ""
|
||
switch msg.String() {
|
||
case "i", "I":
|
||
newCol = "id"
|
||
case "n", "N":
|
||
newCol = "name"
|
||
case "r", "R":
|
||
newCol = "runtime"
|
||
case "g", "G":
|
||
newCol = "image"
|
||
case "s", "S":
|
||
newCol = "status"
|
||
}
|
||
if newCol != "" {
|
||
if m.sortContainerCol == newCol {
|
||
m.sortContainerDesc = !m.sortContainerDesc
|
||
} else {
|
||
m.sortContainerCol = newCol
|
||
m.sortContainerDesc = false
|
||
}
|
||
m.updateContainerTable()
|
||
return m, nil, true
|
||
}
|
||
|
||
case tabLocks:
|
||
newCol := ""
|
||
switch msg.String() {
|
||
case "p", "P":
|
||
newCol = "pid"
|
||
case "n", "N":
|
||
newCol = "process"
|
||
case "t", "T":
|
||
newCol = "type"
|
||
case "m", "M":
|
||
newCol = "mode"
|
||
case "f", "F":
|
||
newCol = "path"
|
||
}
|
||
if newCol != "" {
|
||
if m.sortLockCol == newCol {
|
||
m.sortLockDesc = !m.sortLockDesc
|
||
} else {
|
||
m.sortLockCol = newCol
|
||
m.sortLockDesc = false
|
||
}
|
||
m.updateLockTable()
|
||
return m, nil, true
|
||
}
|
||
}
|
||
return m, nil, false
|
||
}
|
||
|
||
func (m MainModel) handleListNavKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||
var cmd tea.Cmd
|
||
if m.listFocus == focusMain {
|
||
if m.activeTab == tabProcesses {
|
||
prevSelected := -1
|
||
if len(m.filtered) > 0 {
|
||
prevSelected = m.table.Cursor()
|
||
}
|
||
|
||
m.table, cmd = m.table.Update(msg)
|
||
|
||
if len(m.filtered) > 0 && m.table.Cursor() != prevSelected {
|
||
selected := m.table.SelectedRow()
|
||
if len(selected) > 0 {
|
||
idx := m.table.Cursor()
|
||
if idx >= 0 && idx < len(m.filtered) {
|
||
m.selectionID++
|
||
id := m.selectionID
|
||
p := m.filtered[idx]
|
||
debounceCmd := tea.Tick(selectionDebounce, func(_ time.Time) tea.Msg {
|
||
return debounceMsg{id: id, pid: p.PID}
|
||
})
|
||
return m, tea.Batch(cmd, debounceCmd)
|
||
}
|
||
}
|
||
}
|
||
return m, cmd
|
||
} else if m.activeTab == tabContainers {
|
||
m.containerTable, cmd = m.containerTable.Update(msg)
|
||
return m, cmd
|
||
} else if m.activeTab == tabLocks {
|
||
m.lockTable, cmd = m.lockTable.Update(msg)
|
||
return m, cmd
|
||
} else {
|
||
prevSelected := m.portTable.Cursor()
|
||
m.portTable, cmd = m.portTable.Update(msg)
|
||
if m.portTable.Cursor() != prevSelected {
|
||
m.updatePortDetails()
|
||
}
|
||
return m, cmd
|
||
}
|
||
} else {
|
||
if m.activeTab == tabProcesses {
|
||
switch msg.Type {
|
||
case tea.KeyUp:
|
||
if m.treeCursor > 0 {
|
||
m.treeCursor--
|
||
m.rerenderTree()
|
||
}
|
||
case tea.KeyDown:
|
||
if m.treeCursor < len(m.treePIDs)-1 {
|
||
m.treeCursor++
|
||
m.rerenderTree()
|
||
}
|
||
case tea.KeyEnter:
|
||
if m.treeCursor >= 0 && m.treeCursor < len(m.treePIDs) {
|
||
pid := m.treePIDs[m.treeCursor]
|
||
if pid > 0 {
|
||
m.state = stateDetail
|
||
m.viewport.GotoTop()
|
||
m.envViewport.GotoTop()
|
||
return m, m.fetchProcessDetail(pid)
|
||
}
|
||
}
|
||
default:
|
||
m.treeViewport, cmd = m.treeViewport.Update(msg)
|
||
}
|
||
} else {
|
||
m.portDetailTable, cmd = m.portDetailTable.Update(msg)
|
||
}
|
||
return m, cmd
|
||
}
|
||
}
|