Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:22:06 +08:00

1670 lines
42 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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", &currentPID)
}
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", &currentPID)
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
}
}