项目文件夹

文件
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 行
42 KiB
Go

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
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
}
}