mirror of
https://github.com/theniceboy/.config.git
synced 2026-04-13 05:55:14 +08:00
1012 lines
29 KiB
Go
1012 lines
29 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"sort"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
)
|
|
|
|
type activityMonitorBT struct {
|
|
windowID string
|
|
embedded bool
|
|
sortKey activitySortKey
|
|
sortDescending bool
|
|
selectedPID int
|
|
selectedRow int
|
|
rowOffset int
|
|
lockSelection bool
|
|
showAllProcesses bool
|
|
processes map[int]*activityProcess
|
|
rows []activityRow
|
|
memoryByPID map[int]activityMemory
|
|
networkByPID map[int]activityNetwork
|
|
portsByPID map[int][]string
|
|
tmuxByPanePID map[int]*activityTmuxLocation
|
|
lastProcessLoad time.Time
|
|
lastMemoryLoad time.Time
|
|
lastNetworkLoad time.Time
|
|
lastPortLoad time.Time
|
|
lastTmuxLoad time.Time
|
|
refreshedAt time.Time
|
|
status string
|
|
statusUntil time.Time
|
|
confirmKillPID int
|
|
copyOptions []activityCopyOption
|
|
copyOptionIndex int
|
|
width int
|
|
height int
|
|
refreshInFlight bool
|
|
pendingRefresh bool
|
|
pendingForce bool
|
|
showAltHints bool
|
|
requestBack bool
|
|
requestClose bool
|
|
styles activityStyles
|
|
}
|
|
|
|
type activityStyles struct {
|
|
title lipgloss.Style
|
|
meta lipgloss.Style
|
|
header lipgloss.Style
|
|
headerActive lipgloss.Style
|
|
row lipgloss.Style
|
|
rowSelected lipgloss.Style
|
|
cell lipgloss.Style
|
|
cellSelected lipgloss.Style
|
|
detailTitle lipgloss.Style
|
|
detailLabel lipgloss.Style
|
|
detailValue lipgloss.Style
|
|
muted lipgloss.Style
|
|
footer lipgloss.Style
|
|
status lipgloss.Style
|
|
statusBad lipgloss.Style
|
|
divider lipgloss.Style
|
|
shortcutKey lipgloss.Style
|
|
shortcutText lipgloss.Style
|
|
modal lipgloss.Style
|
|
modalTitle lipgloss.Style
|
|
modalBody lipgloss.Style
|
|
modalHint lipgloss.Style
|
|
}
|
|
|
|
type activityTickMsg struct{}
|
|
|
|
type activityRefreshMsg struct {
|
|
snapshot *activitySnapshot
|
|
err error
|
|
}
|
|
|
|
type activityCopyOption struct {
|
|
Label string
|
|
Value string
|
|
}
|
|
|
|
var activityClipboardWriter = writeActivityClipboard
|
|
|
|
func newActivityStyles() activityStyles {
|
|
accent := lipgloss.Color("223")
|
|
cyan := lipgloss.Color("117")
|
|
selected := lipgloss.Color("238")
|
|
text := lipgloss.Color("252")
|
|
muted := lipgloss.Color("245")
|
|
bright := lipgloss.Color("230")
|
|
warning := lipgloss.Color("203")
|
|
success := lipgloss.Color("150")
|
|
return activityStyles{
|
|
title: lipgloss.NewStyle().Bold(true).Foreground(bright),
|
|
meta: lipgloss.NewStyle().Foreground(muted),
|
|
header: lipgloss.NewStyle().Bold(true).Foreground(cyan),
|
|
headerActive: lipgloss.NewStyle().Bold(true).Foreground(accent).Background(selected),
|
|
row: lipgloss.NewStyle().Padding(0, 1),
|
|
rowSelected: lipgloss.NewStyle().Background(selected).Padding(0, 1),
|
|
cell: lipgloss.NewStyle().Foreground(text),
|
|
cellSelected: lipgloss.NewStyle().Foreground(bright).Background(selected),
|
|
detailTitle: lipgloss.NewStyle().Bold(true).Foreground(accent),
|
|
detailLabel: lipgloss.NewStyle().Foreground(cyan),
|
|
detailValue: lipgloss.NewStyle().Foreground(text),
|
|
muted: lipgloss.NewStyle().Foreground(muted),
|
|
footer: lipgloss.NewStyle().Foreground(lipgloss.Color("216")),
|
|
status: lipgloss.NewStyle().Foreground(success),
|
|
statusBad: lipgloss.NewStyle().Foreground(warning),
|
|
divider: lipgloss.NewStyle().Foreground(lipgloss.Color("239")),
|
|
shortcutKey: lipgloss.NewStyle().Foreground(lipgloss.Color("235")).Background(accent).Padding(0, 1).Bold(true),
|
|
shortcutText: lipgloss.NewStyle().Foreground(muted),
|
|
modal: lipgloss.NewStyle().Border(paletteModalBorder).BorderForeground(accent).Padding(1, 2).Background(lipgloss.Color("235")),
|
|
modalTitle: lipgloss.NewStyle().Bold(true).Foreground(warning),
|
|
modalBody: lipgloss.NewStyle().Foreground(text),
|
|
modalHint: lipgloss.NewStyle().Foreground(muted),
|
|
}
|
|
}
|
|
|
|
func newActivityMonitorModel(windowID string, embedded bool) *activityMonitorBT {
|
|
model := &activityMonitorBT{
|
|
windowID: strings.TrimSpace(windowID),
|
|
embedded: embedded,
|
|
sortKey: activitySortCPU,
|
|
sortDescending: true,
|
|
showAllProcesses: true,
|
|
processes: map[int]*activityProcess{},
|
|
memoryByPID: map[int]activityMemory{},
|
|
networkByPID: map[int]activityNetwork{},
|
|
portsByPID: map[int][]string{},
|
|
tmuxByPanePID: map[int]*activityTmuxLocation{},
|
|
styles: newActivityStyles(),
|
|
}
|
|
model.setStatus("Loading...", 0)
|
|
return model
|
|
}
|
|
|
|
func runBubbleTeaActivityMonitor(windowID string) error {
|
|
model := newActivityMonitorModel(windowID, false)
|
|
_, err := tea.NewProgram(model).Run()
|
|
return err
|
|
}
|
|
|
|
func (m *activityMonitorBT) Init() tea.Cmd {
|
|
return tea.Batch(
|
|
activityRequestRefreshBT(true, true, m),
|
|
activityTickCmd(),
|
|
)
|
|
}
|
|
|
|
func (m *activityMonitorBT) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case tea.WindowSizeMsg:
|
|
m.width = msg.Width
|
|
m.height = msg.Height
|
|
return m, nil
|
|
case activityTickMsg:
|
|
return m, tea.Batch(
|
|
activityTickCmd(),
|
|
activityRequestRefreshBT(false, false, m),
|
|
)
|
|
case activityRefreshMsg:
|
|
m.refreshInFlight = false
|
|
if msg.err != nil {
|
|
m.setStatus(msg.err.Error(), 5*time.Second)
|
|
} else if msg.snapshot != nil {
|
|
m.applySnapshot(msg.snapshot)
|
|
if strings.TrimSpace(m.status) == "Loading..." {
|
|
m.setStatus("", 0)
|
|
}
|
|
}
|
|
if m.pendingForce || m.pendingRefresh {
|
|
force := m.pendingForce
|
|
m.pendingForce = false
|
|
m.pendingRefresh = false
|
|
return m, activityRequestRefreshBT(force, false, m)
|
|
}
|
|
return m, nil
|
|
case tea.KeyMsg:
|
|
if isAltFooterToggleKey(msg) {
|
|
m.showAltHints = !m.showAltHints
|
|
return m, nil
|
|
}
|
|
m.showAltHints = false
|
|
key := msg.String()
|
|
if key == "alt+s" {
|
|
if m.embedded {
|
|
m.requestClose = true
|
|
return m, nil
|
|
}
|
|
return m, tea.Quit
|
|
}
|
|
if len(m.copyOptions) > 0 {
|
|
return m.updateCopyMenu(key)
|
|
}
|
|
if m.confirmKillPID != 0 {
|
|
return m.updateConfirmKill(key)
|
|
}
|
|
return m.updateNormal(key)
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m *activityMonitorBT) updateConfirmKill(key string) (tea.Model, tea.Cmd) {
|
|
if key == "esc" || key == "n" || key == "N" {
|
|
m.confirmKillPID = 0
|
|
return m, nil
|
|
}
|
|
if key == "y" || key == "Y" || key == "enter" {
|
|
pid := m.confirmKillPID
|
|
m.confirmKillPID = 0
|
|
if err := m.killProcess(pid); err != nil {
|
|
m.setStatus(err.Error(), 5*time.Second)
|
|
}
|
|
return m, activityRequestRefreshBT(true, false, m)
|
|
}
|
|
m.confirmKillPID = 0
|
|
return m, nil
|
|
}
|
|
|
|
func (m *activityMonitorBT) updateCopyMenu(key string) (tea.Model, tea.Cmd) {
|
|
if len(m.copyOptions) == 0 {
|
|
return m, nil
|
|
}
|
|
switch key {
|
|
case "esc", "n", "N":
|
|
m.copyOptions = nil
|
|
m.copyOptionIndex = 0
|
|
return m, nil
|
|
case "u", "up", "ctrl+u":
|
|
m.copyOptionIndex = clampInt(m.copyOptionIndex-1, 0, len(m.copyOptions)-1)
|
|
return m, nil
|
|
case "e", "down", "ctrl+e":
|
|
m.copyOptionIndex = clampInt(m.copyOptionIndex+1, 0, len(m.copyOptions)-1)
|
|
return m, nil
|
|
case "enter", "y", "Y":
|
|
option := m.copyOptions[clampInt(m.copyOptionIndex, 0, len(m.copyOptions)-1)]
|
|
if err := activityClipboardWriter(option.Value); err != nil {
|
|
m.setStatus(err.Error(), 5*time.Second)
|
|
return m, nil
|
|
}
|
|
m.copyOptions = nil
|
|
m.copyOptionIndex = 0
|
|
m.setStatus("Copied "+strings.ToLower(strings.TrimSpace(option.Label)), 4*time.Second)
|
|
return m, nil
|
|
}
|
|
if idx, ok := activityCopyIndexForKey(key, len(m.copyOptions)); ok {
|
|
option := m.copyOptions[idx]
|
|
if err := activityClipboardWriter(option.Value); err != nil {
|
|
m.setStatus(err.Error(), 5*time.Second)
|
|
return m, nil
|
|
}
|
|
m.copyOptions = nil
|
|
m.copyOptionIndex = 0
|
|
m.setStatus("Copied "+strings.ToLower(strings.TrimSpace(option.Label)), 4*time.Second)
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m *activityMonitorBT) updateNormal(key string) (tea.Model, tea.Cmd) {
|
|
if key == "esc" || key == "ctrl+c" {
|
|
if m.embedded {
|
|
m.requestBack = true
|
|
return m, nil
|
|
}
|
|
return m, tea.Quit
|
|
}
|
|
switch key {
|
|
case "u", "up":
|
|
m.moveSelection(-1)
|
|
case "e", "down":
|
|
m.moveSelection(1)
|
|
case "n", "left":
|
|
m.shiftSort(-1)
|
|
case "i", "right":
|
|
m.shiftSort(1)
|
|
case "a":
|
|
m.showAllProcesses = !m.showAllProcesses
|
|
m.resortRows()
|
|
return m, activityRequestRefreshBT(true, false, m)
|
|
case "l":
|
|
m.lockSelection = !m.lockSelection
|
|
if !m.lockSelection {
|
|
m.resortRows()
|
|
}
|
|
case "r":
|
|
m.sortDescending = !m.sortDescending
|
|
m.resortRows()
|
|
case "c":
|
|
m.setSort(activitySortCPU)
|
|
case "m":
|
|
m.setSort(activitySortMemory)
|
|
case "j":
|
|
m.setSort(activitySortDownload)
|
|
case "k":
|
|
m.setSort(activitySortUpload)
|
|
case "o":
|
|
m.setSort(activitySortPorts)
|
|
case "t", "w":
|
|
m.setSort(activitySortLocation)
|
|
case "f":
|
|
m.setSort(activitySortCommand)
|
|
case "enter", "g":
|
|
proc := m.selectedProcess()
|
|
if proc == nil || proc.Tmux == nil {
|
|
m.setStatus("Not in tmux", 3*time.Second)
|
|
return m, nil
|
|
}
|
|
if err := focusActivityTmuxLocation(proc.Tmux); err != nil {
|
|
m.setStatus(err.Error(), 4*time.Second)
|
|
return m, nil
|
|
}
|
|
if m.embedded {
|
|
m.requestClose = true
|
|
return m, nil
|
|
}
|
|
return m, tea.Quit
|
|
case "d", "D":
|
|
if proc := m.selectedProcess(); proc != nil {
|
|
m.confirmKillPID = proc.PID
|
|
}
|
|
case "y", "Y":
|
|
proc := m.selectedProcess()
|
|
if proc == nil {
|
|
m.setStatus("No process selected", 3*time.Second)
|
|
return m, nil
|
|
}
|
|
m.copyOptions = activityCopyOptions(proc)
|
|
m.copyOptionIndex = 0
|
|
if len(m.copyOptions) == 0 {
|
|
m.setStatus("Nothing available to copy", 3*time.Second)
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m *activityMonitorBT) View() string {
|
|
w := m.width
|
|
h := m.height
|
|
if w < 72 || h < 14 {
|
|
return m.styles.title.Render("Window too small")
|
|
}
|
|
|
|
title := fmt.Sprintf("Activity Monitor %d processes", len(m.rows))
|
|
headerLine := m.styles.title.Render(title)
|
|
|
|
scope := "tmux"
|
|
if m.showAllProcesses {
|
|
scope = "all"
|
|
}
|
|
dir := "asc"
|
|
if m.sortDescending {
|
|
dir = "desc"
|
|
}
|
|
follow := "row"
|
|
if m.lockSelection {
|
|
follow = "item"
|
|
}
|
|
metaLine := m.styles.meta.Render(fmt.Sprintf("Scope %s Sort %s %s Follow %s", scope, m.sortKey.label(), dir, follow))
|
|
|
|
tableX := 1
|
|
tableY := 3
|
|
contentH := h - tableY - 1
|
|
tableW := w - 2
|
|
previewX := 0
|
|
previewW := 0
|
|
showPreview := w >= 108
|
|
if showPreview {
|
|
tableW = maxInt(70, (w-3)*68/100)
|
|
previewX = tableX + tableW + 1
|
|
previewW = w - previewX - 1
|
|
}
|
|
|
|
tableContent := m.renderTable(tableW, contentH)
|
|
left := lipgloss.NewStyle().Width(tableW).Height(contentH).Render(tableContent)
|
|
|
|
body := left
|
|
if showPreview {
|
|
divider := m.renderVerticalDivider(contentH)
|
|
rightContent := m.renderDetails(previewW, contentH)
|
|
right := lipgloss.NewStyle().Width(previewW).Height(contentH).Render(rightContent)
|
|
body = lipgloss.JoinHorizontal(lipgloss.Top, left, divider, right)
|
|
}
|
|
|
|
footer := m.renderFooter(w)
|
|
|
|
view := lipgloss.JoinVertical(lipgloss.Left,
|
|
headerLine,
|
|
metaLine,
|
|
"",
|
|
body,
|
|
"",
|
|
footer,
|
|
)
|
|
|
|
result := lipgloss.NewStyle().Width(w).Height(h).Padding(0, 1).Render(view)
|
|
|
|
if m.confirmKillPID != 0 {
|
|
procName := fmt.Sprintf("PID %d", m.confirmKillPID)
|
|
if proc := m.processes[m.confirmKillPID]; proc != nil {
|
|
procName = fmt.Sprintf("%s (PID %d)", proc.ShortCommand, proc.PID)
|
|
}
|
|
modal := lipgloss.JoinVertical(lipgloss.Left,
|
|
m.styles.modalTitle.Render("Kill process"),
|
|
m.styles.modalBody.Render(procName),
|
|
"",
|
|
m.styles.modalHint.Render("y confirm n cancel"),
|
|
)
|
|
box := m.styles.modal.Render(modal)
|
|
result = lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, box, lipgloss.WithWhitespaceChars(" "), lipgloss.WithWhitespaceBackground(lipgloss.Color("235")))
|
|
} else if len(m.copyOptions) > 0 {
|
|
rows := make([]string, 0, len(m.copyOptions))
|
|
for idx, option := range m.copyOptions {
|
|
prefix := fmt.Sprintf("%d ", idx+1)
|
|
labelStyle := m.styles.modalBody
|
|
rowStyle := lipgloss.NewStyle()
|
|
if idx == clampInt(m.copyOptionIndex, 0, len(m.copyOptions)-1) {
|
|
rowStyle = rowStyle.Background(lipgloss.Color("238"))
|
|
labelStyle = labelStyle.Copy().Background(lipgloss.Color("238")).Foreground(lipgloss.Color("230"))
|
|
}
|
|
rows = append(rows, rowStyle.Render(prefix+labelStyle.Render(option.Label)))
|
|
}
|
|
modal := lipgloss.JoinVertical(lipgloss.Left,
|
|
m.styles.modalTitle.Render("Copy"),
|
|
m.styles.modalBody.Render("Choose a field from the selected process"),
|
|
"",
|
|
strings.Join(rows, "\n"),
|
|
"",
|
|
m.styles.modalHint.Render("u/e move enter copy 1-9 direct esc cancel"),
|
|
)
|
|
box := m.styles.modal.Width(minInt(48, maxInt(30, w-10))).Render(modal)
|
|
result = lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, box, lipgloss.WithWhitespaceChars(" "), lipgloss.WithWhitespaceBackground(lipgloss.Color("235")))
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
func (m *activityMonitorBT) renderTable(width, height int) string {
|
|
cpuW, memW, downW, upW, procW, sessionW, windowW, portW := activityColumnWidths(width)
|
|
|
|
headers := []struct {
|
|
key activitySortKey
|
|
label string
|
|
w int
|
|
}{
|
|
{activitySortCPU, "CPU", cpuW},
|
|
{activitySortMemory, "MEM", memW},
|
|
{activitySortDownload, "DOWN", downW},
|
|
{activitySortUpload, "UP", upW},
|
|
{activitySortCommand, "PROCESS", procW},
|
|
{activitySortLocation, "SESSION", sessionW},
|
|
{activitySortLocation, "WIN", windowW},
|
|
{activitySortPorts, "PORTS", portW},
|
|
}
|
|
|
|
headerParts := make([]string, len(headers))
|
|
for i, h := range headers {
|
|
style := m.styles.header
|
|
label := h.label
|
|
if h.key == m.sortKey {
|
|
style = m.styles.headerActive
|
|
if m.sortDescending {
|
|
label += " ▼"
|
|
} else {
|
|
label += " ▲"
|
|
}
|
|
}
|
|
headerParts[i] = style.Width(h.w).Render(truncate(label, h.w))
|
|
}
|
|
headerLine := lipgloss.JoinHorizontal(lipgloss.Left, headerParts...)
|
|
|
|
visibleRows := maxInt(1, height-1)
|
|
selectedIndex := m.selectedIndex()
|
|
if selectedIndex < 0 {
|
|
selectedIndex = 0
|
|
}
|
|
offset := stableListOffset(m.rowOffset, selectedIndex, visibleRows, len(m.rows))
|
|
m.rowOffset = offset
|
|
|
|
rowLines := []string{}
|
|
for row := 0; row < visibleRows; row++ {
|
|
idx := offset + row
|
|
if idx >= len(m.rows) {
|
|
break
|
|
}
|
|
info := m.rows[idx]
|
|
proc := m.processes[info.PID]
|
|
if proc == nil {
|
|
continue
|
|
}
|
|
selected := proc.PID == m.selectedPID
|
|
rowStyle := m.styles.row
|
|
cellStyle := m.styles.cell
|
|
if selected {
|
|
rowStyle = m.styles.rowSelected
|
|
cellStyle = m.styles.cellSelected
|
|
}
|
|
|
|
cpuStyle := cellStyle
|
|
if proc.CPU >= 50 {
|
|
cpuStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203")).Background(cellStyle.GetBackground())
|
|
} else if proc.CPU >= 20 {
|
|
cpuStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("216")).Background(cellStyle.GetBackground())
|
|
}
|
|
|
|
memStyle := cellStyle
|
|
totalMem := activityTotalMemoryMB(proc)
|
|
if totalMem >= 1024 {
|
|
memStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203")).Background(cellStyle.GetBackground())
|
|
} else if totalMem >= 512 {
|
|
memStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("216")).Background(cellStyle.GetBackground())
|
|
}
|
|
|
|
cells := []string{
|
|
cpuStyle.Width(cpuW).Render(truncate(fmt.Sprintf("%.1f", proc.CPU), cpuW)),
|
|
memStyle.Width(memW).Render(truncate(formatActivityMB(totalMem), memW)),
|
|
cellStyle.Width(downW).Render(truncate(formatActivitySpeed(proc.DownKBps), downW)),
|
|
cellStyle.Width(upW).Render(truncate(formatActivitySpeed(proc.UpKBps), upW)),
|
|
cellStyle.Width(procW).Render(truncate(activityProcessLabel(proc), procW)),
|
|
cellStyle.Width(sessionW).Render(truncate(activitySessionLabel(proc.Tmux), sessionW)),
|
|
cellStyle.Width(windowW).Render(truncate(activityWindowLabel(proc.Tmux, m.windowID), windowW)),
|
|
cellStyle.Width(portW).Render(truncate(formatActivityPorts(proc.Ports), portW)),
|
|
}
|
|
rowLines = append(rowLines, rowStyle.Width(width).Render(lipgloss.JoinHorizontal(lipgloss.Left, cells...)))
|
|
}
|
|
|
|
if len(rowLines) == 0 {
|
|
msg := "No tmux processes"
|
|
if m.showAllProcesses {
|
|
msg = "No processes"
|
|
}
|
|
return headerLine + "\n" + m.styles.muted.Width(width).Render(msg)
|
|
}
|
|
|
|
return headerLine + "\n" + strings.Join(rowLines, "\n")
|
|
}
|
|
|
|
func (m *activityMonitorBT) renderDetails(width, height int) string {
|
|
lines := []string{m.styles.detailTitle.Render("Details")}
|
|
|
|
proc := m.selectedProcess()
|
|
if proc == nil {
|
|
lines = append(lines, m.styles.muted.Render("No process selected"))
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
lines = append(lines,
|
|
m.renderDetailRow("PID", fmt.Sprintf("%d", proc.PID), width),
|
|
m.renderDetailRow("PPID", fmt.Sprintf("%d", proc.PPID), width),
|
|
m.renderDetailRow("CPU", fmt.Sprintf("%.1f%%", proc.CPU), width),
|
|
m.renderDetailRow("MEM", formatActivityMB(activityTotalMemoryMB(proc)), width),
|
|
m.renderDetailRow("Download", formatActivitySpeed(proc.DownKBps), width),
|
|
m.renderDetailRow("Upload", formatActivitySpeed(proc.UpKBps), width),
|
|
)
|
|
|
|
if proc.ResidentMB > 0 || proc.CompressedMB > 0 {
|
|
lines = append(lines, m.renderDetailRow("Resident", formatActivityMB(proc.ResidentMB), width))
|
|
if proc.CompressedMB > 0 {
|
|
lines = append(lines, m.renderDetailRow("Compressed", formatActivityMB(proc.CompressedMB), width))
|
|
}
|
|
}
|
|
|
|
lines = append(lines,
|
|
m.renderDetailRow("State", blankIfEmpty(proc.State, "-"), width),
|
|
m.renderDetailRow("Elapsed", blankIfEmpty(proc.Elapsed, "-"), width),
|
|
)
|
|
|
|
if proc.Tmux == nil {
|
|
lines = append(lines, m.renderDetailRow("Tmux", "outside tmux", width))
|
|
} else {
|
|
lines = append(lines,
|
|
m.renderDetailRow("Session", blankIfEmpty(proc.Tmux.SessionName, proc.Tmux.SessionID), width),
|
|
m.renderDetailRow("Window", activityWindowLabel(proc.Tmux, m.windowID), width),
|
|
m.renderDetailRow("Pane", proc.Tmux.PaneIndex, width),
|
|
)
|
|
}
|
|
|
|
if len(proc.Ports) > 0 {
|
|
lines = append(lines, m.renderDetailRow("Ports", strings.Join(proc.Ports, ", "), width))
|
|
} else {
|
|
lines = append(lines, m.renderDetailRow("Ports", "none", width))
|
|
}
|
|
|
|
lines = append(lines, "", m.styles.detailTitle.Render("Command"))
|
|
cmdLines := wrapText(blankIfEmpty(proc.Command, proc.ShortCommand), width)
|
|
for _, l := range cmdLines {
|
|
lines = append(lines, m.styles.cell.Render(truncate(l, width)))
|
|
}
|
|
|
|
return clampActivityLines(lines, height, width, m.styles.muted)
|
|
}
|
|
|
|
func clampActivityLines(lines []string, height, width int, muted lipgloss.Style) string {
|
|
if height <= 0 {
|
|
return ""
|
|
}
|
|
if len(lines) <= height {
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
if height == 1 {
|
|
return muted.Render(truncate("...", width))
|
|
}
|
|
clipped := append([]string(nil), lines[:height]...)
|
|
clipped[height-1] = muted.Render(truncate("...", width))
|
|
return strings.Join(clipped, "\n")
|
|
}
|
|
|
|
func (m *activityMonitorBT) renderDetailRow(label, value string, width int) string {
|
|
labelW := 10
|
|
return m.styles.detailLabel.Width(labelW).Render(label+":") + " " + m.styles.detailValue.Render(truncate(value, maxInt(10, width-labelW-2)))
|
|
}
|
|
|
|
func (m *activityMonitorBT) renderVerticalDivider(height int) string {
|
|
lines := make([]string, maxInt(1, height))
|
|
for i := range lines {
|
|
lines[i] = m.styles.divider.Render("│")
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func (m *activityMonitorBT) renderFooter(width int) string {
|
|
status := m.currentStatus()
|
|
if status != "" {
|
|
style := m.styles.status
|
|
lower := strings.ToLower(status)
|
|
if strings.Contains(lower, "error") || strings.Contains(lower, "failed") || strings.Contains(lower, "refusing") {
|
|
style = m.styles.statusBad
|
|
}
|
|
return style.Width(width).Render(truncate(status, width))
|
|
}
|
|
|
|
renderSegments := func(pairs [][2]string) string {
|
|
return renderShortcutPairs(func(v string) string { return m.styles.shortcutKey.Render(v) }, func(v string) string { return m.styles.shortcutText.Render(v) }, " ", pairs)
|
|
}
|
|
footer := ""
|
|
if m.showAltHints {
|
|
footer = pickRenderedShortcutFooter(width, renderSegments,
|
|
[][2]string{{"Alt-S", "close"}, {footerHintToggleKey, "hide"}},
|
|
[][2]string{{"Alt-S", "close"}},
|
|
)
|
|
} else {
|
|
footer = pickRenderedShortcutFooter(width, renderSegments,
|
|
[][2]string{{"u/e", "move"}, {"n/i", "sort"}, {"j/k", "net"}, {"a", "scope"}, {"l", "follow"}, {"r", "reverse"}, {"Enter", "tmux"}, {"d", "kill"}, {"y", "copy"}, {"Esc", "back"}, {footerHintToggleKey, "more"}},
|
|
[][2]string{{"u/e", "move"}, {"n/i", "sort"}, {"j/k", "net"}, {"a", "scope"}, {"Enter", "tmux"}, {"d", "kill"}, {"y", "copy"}, {"Esc", "back"}, {footerHintToggleKey, "more"}},
|
|
[][2]string{{"u/e", "move"}, {"Enter", "tmux"}, {"y", "copy"}, {"Esc", "back"}, {footerHintToggleKey, "more"}},
|
|
)
|
|
}
|
|
return lipgloss.NewStyle().Width(width).Render(footer)
|
|
}
|
|
|
|
func (m *activityMonitorBT) moveSelection(delta int) {
|
|
if len(m.rows) == 0 {
|
|
m.selectedPID = 0
|
|
m.selectedRow = 0
|
|
return
|
|
}
|
|
m.ensureSelection()
|
|
idx := m.selectedRow + delta
|
|
if idx < 0 {
|
|
idx = 0
|
|
}
|
|
if idx >= len(m.rows) {
|
|
idx = len(m.rows) - 1
|
|
}
|
|
m.selectedRow = idx
|
|
m.selectedPID = m.rows[m.selectedRow].PID
|
|
}
|
|
|
|
func (m *activityMonitorBT) shiftSort(delta int) {
|
|
index := 0
|
|
for idx, key := range activitySortKeys {
|
|
if key == m.sortKey {
|
|
index = idx
|
|
break
|
|
}
|
|
}
|
|
index += delta
|
|
if index < 0 {
|
|
index = len(activitySortKeys) - 1
|
|
}
|
|
if index >= len(activitySortKeys) {
|
|
index = 0
|
|
}
|
|
key := activitySortKeys[index]
|
|
if key == m.sortKey {
|
|
return
|
|
}
|
|
m.sortKey = key
|
|
m.sortDescending = defaultActivitySortDescending(key)
|
|
m.resortRows()
|
|
}
|
|
|
|
func (m *activityMonitorBT) killProcess(pid int) error {
|
|
if pid <= 0 {
|
|
return fmt.Errorf("invalid pid")
|
|
}
|
|
if pid == os.Getpid() {
|
|
return fmt.Errorf("refusing to kill self")
|
|
}
|
|
if err := syscall.Kill(pid, syscall.SIGTERM); err != nil {
|
|
return err
|
|
}
|
|
m.setStatus(fmt.Sprintf("Sent SIGTERM to %d", pid), 4*time.Second)
|
|
return nil
|
|
}
|
|
|
|
func (m *activityMonitorBT) setSort(key activitySortKey) {
|
|
if m.sortKey == key {
|
|
m.sortDescending = !m.sortDescending
|
|
} else {
|
|
m.sortKey = key
|
|
m.sortDescending = defaultActivitySortDescending(key)
|
|
}
|
|
m.resortRows()
|
|
}
|
|
|
|
func (m *activityMonitorBT) resortRows() {
|
|
previousPID := m.selectedPID
|
|
previousRow := m.selectedRow
|
|
rows := make([]activityRow, 0, len(m.processes))
|
|
for _, proc := range m.sortedProcesses(m.visibleProcesses()) {
|
|
rows = append(rows, activityRow{PID: proc.PID})
|
|
}
|
|
m.rows = rows
|
|
m.restoreSelection(previousPID, previousRow)
|
|
}
|
|
|
|
func (m *activityMonitorBT) visibleProcesses() []*activityProcess {
|
|
visible := make([]*activityProcess, 0, len(m.processes))
|
|
for _, proc := range m.processes {
|
|
if !m.showAllProcesses && proc.Tmux == nil {
|
|
continue
|
|
}
|
|
visible = append(visible, proc)
|
|
}
|
|
return visible
|
|
}
|
|
|
|
func (m *activityMonitorBT) sortedProcesses(values []*activityProcess) []*activityProcess {
|
|
cloned := append([]*activityProcess(nil), values...)
|
|
sort.SliceStable(cloned, func(i, j int) bool {
|
|
return m.less(cloned[i], cloned[j])
|
|
})
|
|
return cloned
|
|
}
|
|
|
|
func (m *activityMonitorBT) less(left, right *activityProcess) bool {
|
|
cmp := 0
|
|
switch m.sortKey {
|
|
case activitySortCPU:
|
|
cmp = compareFloat64(left.CPU, right.CPU)
|
|
case activitySortMemory:
|
|
cmp = compareFloat64(activityTotalMemoryMB(left), activityTotalMemoryMB(right))
|
|
case activitySortDownload:
|
|
cmp = compareFloat64(left.DownKBps, right.DownKBps)
|
|
case activitySortUpload:
|
|
cmp = compareFloat64(left.UpKBps, right.UpKBps)
|
|
case activitySortPorts:
|
|
cmp = compareInt(len(left.Ports), len(right.Ports))
|
|
if cmp == 0 {
|
|
cmp = strings.Compare(strings.Join(left.Ports, ","), strings.Join(right.Ports, ","))
|
|
}
|
|
case activitySortLocation:
|
|
cmp = compareActivityLocation(left.Tmux, right.Tmux)
|
|
case activitySortCommand:
|
|
cmp = strings.Compare(strings.ToLower(blankIfEmpty(left.ShortCommand, left.Command)), strings.ToLower(blankIfEmpty(right.ShortCommand, right.Command)))
|
|
}
|
|
if cmp == 0 {
|
|
cmp = compareFloat64(left.CPU, right.CPU)
|
|
}
|
|
if cmp == 0 {
|
|
cmp = compareFloat64(activityTotalMemoryMB(left), activityTotalMemoryMB(right))
|
|
}
|
|
if cmp == 0 {
|
|
cmp = compareInt(left.PID, right.PID)
|
|
}
|
|
if m.sortDescending {
|
|
return cmp > 0
|
|
}
|
|
return cmp < 0
|
|
}
|
|
|
|
func (m *activityMonitorBT) ensureSelection() {
|
|
if len(m.rows) == 0 {
|
|
m.selectedPID = 0
|
|
m.selectedRow = 0
|
|
return
|
|
}
|
|
if m.lockSelection && m.selectedPID != 0 {
|
|
for idx, row := range m.rows {
|
|
if row.PID == m.selectedPID {
|
|
m.selectedRow = idx
|
|
return
|
|
}
|
|
}
|
|
}
|
|
if m.selectedRow < 0 {
|
|
m.selectedRow = 0
|
|
}
|
|
if m.selectedRow >= len(m.rows) {
|
|
m.selectedRow = len(m.rows) - 1
|
|
}
|
|
m.selectedPID = m.rows[m.selectedRow].PID
|
|
}
|
|
|
|
func (m *activityMonitorBT) restoreSelection(previousPID, previousRow int) {
|
|
if len(m.rows) == 0 {
|
|
m.selectedPID = 0
|
|
m.selectedRow = 0
|
|
return
|
|
}
|
|
if m.lockSelection && previousPID != 0 {
|
|
for idx, row := range m.rows {
|
|
if row.PID == previousPID {
|
|
m.selectedPID = previousPID
|
|
m.selectedRow = idx
|
|
return
|
|
}
|
|
}
|
|
}
|
|
if previousRow < 0 {
|
|
previousRow = 0
|
|
}
|
|
if previousRow >= len(m.rows) {
|
|
previousRow = len(m.rows) - 1
|
|
}
|
|
m.selectedRow = previousRow
|
|
m.selectedPID = m.rows[m.selectedRow].PID
|
|
}
|
|
|
|
func (m *activityMonitorBT) selectedIndex() int {
|
|
m.ensureSelection()
|
|
if len(m.rows) == 0 {
|
|
return -1
|
|
}
|
|
return m.selectedRow
|
|
}
|
|
|
|
func (m *activityMonitorBT) selectedProcess() *activityProcess {
|
|
m.ensureSelection()
|
|
if m.selectedPID == 0 {
|
|
return nil
|
|
}
|
|
return m.processes[m.selectedPID]
|
|
}
|
|
|
|
func (m *activityMonitorBT) setStatus(text string, duration time.Duration) {
|
|
m.status = strings.TrimSpace(text)
|
|
if duration > 0 {
|
|
m.statusUntil = time.Now().Add(duration)
|
|
} else {
|
|
m.statusUntil = time.Time{}
|
|
}
|
|
}
|
|
|
|
func (m *activityMonitorBT) currentStatus() string {
|
|
if m.status == "" {
|
|
return ""
|
|
}
|
|
if !m.statusUntil.IsZero() && time.Now().After(m.statusUntil) {
|
|
m.status = ""
|
|
m.statusUntil = time.Time{}
|
|
return ""
|
|
}
|
|
return m.status
|
|
}
|
|
|
|
func (m *activityMonitorBT) applySnapshot(snapshot *activitySnapshot) {
|
|
if snapshot == nil {
|
|
return
|
|
}
|
|
m.processes = snapshot.Processes
|
|
m.memoryByPID = snapshot.MemoryByPID
|
|
m.networkByPID = snapshot.NetworkByPID
|
|
m.portsByPID = snapshot.PortsByPID
|
|
m.tmuxByPanePID = snapshot.TmuxByPanePID
|
|
m.lastProcessLoad = snapshot.LastProcessLoad
|
|
m.lastMemoryLoad = snapshot.LastMemoryLoad
|
|
m.lastNetworkLoad = snapshot.LastNetworkLoad
|
|
m.lastPortLoad = snapshot.LastPortLoad
|
|
m.lastTmuxLoad = snapshot.LastTmuxLoad
|
|
m.refreshedAt = snapshot.RefreshedAt
|
|
m.resortRows()
|
|
if strings.TrimSpace(snapshot.Status) != "" && m.currentStatus() == "" {
|
|
m.setStatus(snapshot.Status, 4*time.Second)
|
|
}
|
|
}
|
|
|
|
func activityTickCmd() tea.Cmd {
|
|
return tea.Tick(time.Second, func(time.Time) tea.Msg {
|
|
return activityTickMsg{}
|
|
})
|
|
}
|
|
|
|
func activityRequestRefreshBT(force, initial bool, m *activityMonitorBT) tea.Cmd {
|
|
if m.refreshInFlight {
|
|
if force || initial {
|
|
m.pendingForce = true
|
|
} else {
|
|
m.pendingRefresh = true
|
|
}
|
|
return nil
|
|
}
|
|
m.refreshInFlight = true
|
|
input := activityRefreshInput{
|
|
Force: force,
|
|
Initial: initial,
|
|
ShowAll: m.showAllProcesses,
|
|
Processes: cloneActivityProcesses(m.processes),
|
|
MemoryByPID: cloneActivityMemoryMap(m.memoryByPID),
|
|
NetworkByPID: cloneActivityNetworkMap(m.networkByPID),
|
|
PortsByPID: cloneActivityPortsMap(m.portsByPID),
|
|
TmuxByPanePID: cloneActivityTmuxMap(m.tmuxByPanePID),
|
|
LastProcessLoad: m.lastProcessLoad,
|
|
LastMemoryLoad: m.lastMemoryLoad,
|
|
LastNetworkLoad: m.lastNetworkLoad,
|
|
LastPortLoad: m.lastPortLoad,
|
|
LastTmuxLoad: m.lastTmuxLoad,
|
|
RefreshedAt: m.refreshedAt,
|
|
}
|
|
return func() tea.Msg {
|
|
snapshot, err := collectActivitySnapshot(input)
|
|
return activityRefreshMsg{snapshot: snapshot, err: err}
|
|
}
|
|
}
|
|
|
|
func activityCopyOptions(proc *activityProcess) []activityCopyOption {
|
|
if proc == nil {
|
|
return nil
|
|
}
|
|
options := []activityCopyOption{
|
|
{Label: "PID", Value: fmt.Sprintf("%d", proc.PID)},
|
|
{Label: "Parent PID", Value: fmt.Sprintf("%d", proc.PPID)},
|
|
{Label: "Process", Value: blankIfEmpty(proc.ShortCommand, proc.Command)},
|
|
{Label: "Command", Value: blankIfEmpty(proc.Command, proc.ShortCommand)},
|
|
{Label: "Download", Value: formatActivitySpeed(proc.DownKBps)},
|
|
{Label: "Upload", Value: formatActivitySpeed(proc.UpKBps)},
|
|
}
|
|
if proc.Tmux != nil {
|
|
session := strings.TrimSpace(blankIfEmpty(proc.Tmux.SessionName, proc.Tmux.SessionID))
|
|
if session != "" {
|
|
if strings.TrimSpace(proc.Tmux.SessionID) != "" && session != strings.TrimSpace(proc.Tmux.SessionID) {
|
|
session += " (" + strings.TrimSpace(proc.Tmux.SessionID) + ")"
|
|
}
|
|
options = append(options, activityCopyOption{Label: "Session", Value: session})
|
|
}
|
|
window := activityWindowLabel(proc.Tmux, "")
|
|
if strings.TrimSpace(window) != "-" && strings.TrimSpace(window) != "" {
|
|
if strings.TrimSpace(proc.Tmux.WindowID) != "" {
|
|
window += " (" + strings.TrimSpace(proc.Tmux.WindowID) + ")"
|
|
}
|
|
options = append(options, activityCopyOption{Label: "Window", Value: window})
|
|
}
|
|
if pane := strings.TrimSpace(proc.Tmux.PaneID); pane != "" {
|
|
options = append(options, activityCopyOption{Label: "Pane", Value: pane})
|
|
}
|
|
}
|
|
if len(proc.Ports) > 0 {
|
|
options = append(options, activityCopyOption{Label: "Ports", Value: strings.Join(proc.Ports, ", ")})
|
|
}
|
|
filtered := make([]activityCopyOption, 0, len(options))
|
|
for _, option := range options {
|
|
if strings.TrimSpace(option.Value) == "" || strings.TrimSpace(option.Value) == "-" {
|
|
continue
|
|
}
|
|
filtered = append(filtered, option)
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
func activityCopyIndexForKey(key string, total int) (int, bool) {
|
|
if total <= 0 {
|
|
return 0, false
|
|
}
|
|
runes := []rune(strings.TrimSpace(key))
|
|
if len(runes) != 1 {
|
|
return 0, false
|
|
}
|
|
if runes[0] < '1' || runes[0] > '9' {
|
|
return 0, false
|
|
}
|
|
idx := int(runes[0] - '1')
|
|
if idx < 0 || idx >= total {
|
|
return 0, false
|
|
}
|
|
return idx, true
|
|
}
|
|
|
|
func writeActivityClipboard(value string) error {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
return fmt.Errorf("nothing to copy")
|
|
}
|
|
cmd := exec.Command("pbcopy")
|
|
cmd.Stdin = strings.NewReader(value)
|
|
if output, err := cmd.CombinedOutput(); err != nil {
|
|
message := strings.TrimSpace(string(output))
|
|
if message == "" {
|
|
return err
|
|
}
|
|
return fmt.Errorf("clipboard copy failed: %s", message)
|
|
}
|
|
return nil
|
|
}
|